"""
XslDependencyDialog - Dialog zur Anzeige des XSL-Abhängigkeitsgraphen.

Zeigt alle XSL-Dateien im konfigurierten Verzeichnis mit ihren
Import/Include-Beziehungen (vorwärts und rückwärts).
Bietet zwei Ansichten: Baumansicht und interaktiver Netzwerkgraph (vis.js).
"""

import json
import logging
import tempfile
from pathlib import Path

from PySide6.QtCore import QUrl, Qt
from icons import icon, IconRefreshMixin
from PySide6.QtWidgets import (
    QComboBox,
    QDialog,
    QDoubleSpinBox,
    QFormLayout,
    QLabel,
    QSpinBox,
    QStackedWidget,
    QTreeWidgetItem,
    QWidget,
)

from conf import (
    GraphLayout,
    GraphLayoutSettings,
    HierarchicalDirection,
    HierarchicalSortMethod,
    app_settings,
)
from ui.XslDependencyDialog_ui import Ui_XslDependencyDialog
from xsl_dependencies import XslDependencyGraph

logger = logging.getLogger(__name__)


def _build_physics_options_dict(s: GraphLayoutSettings) -> str:
    """
    Generiert den vis.js Physics/Layout-Optionsblock als JSON-String.

    Args:
        s: Aktuelle GraphLayoutSettings

    Returns:
        str: JSON-String für network.setOptions()
    """
    if s.layout == GraphLayout.HIERARCHICAL:
        return json.dumps(
            {
                "physics": {"enabled": False},
                "layout": {
                    "hierarchical": {
                        "enabled": True,
                        "direction": s.hi_direction.value,
                        "sortMethod": s.hi_sort_method.value,
                        "levelSeparation": s.hi_level_separation,
                        "nodeSpacing": s.hi_node_spacing,
                        "treeSpacing": s.hi_tree_spacing,
                    }
                },
            }
        )

    physics_params: dict = {"solver": s.layout.value}

    if s.layout == GraphLayout.BARNES_HUT:
        physics_params["barnesHut"] = {
            "gravitationalConstant": s.bh_gravitational_constant,
            "centralGravity": s.bh_central_gravity,
            "springLength": s.bh_spring_length,
            "springConstant": s.bh_spring_constant,
            "damping": s.bh_damping,
        }
    elif s.layout == GraphLayout.FORCE_ATLAS2:
        physics_params["forceAtlas2Based"] = {
            "gravitationalConstant": s.fa_gravitational_constant,
            "centralGravity": s.fa_central_gravity,
            "springLength": s.fa_spring_length,
            "springConstant": s.fa_spring_constant,
            "damping": s.fa_damping,
        }
    elif s.layout == GraphLayout.REPULSION:
        physics_params["repulsion"] = {
            "nodeDistance": s.re_node_distance,
            "centralGravity": s.re_central_gravity,
            "springLength": s.re_spring_length,
            "springConstant": s.re_spring_constant,
            "damping": s.re_damping,
        }

    physics_params["stabilization"] = {"enabled": True, "iterations": 200, "updateInterval": 25}

    return json.dumps({"physics": physics_params, "layout": {"hierarchical": {"enabled": False}}})


def _build_set_options_js(s: GraphLayoutSettings) -> str:
    """
    Generiert den JavaScript-Aufruf network.setOptions(...) als String.

    Args:
        s: Aktuelle GraphLayoutSettings

    Returns:
        str: JavaScript-Code für runJavaScript()
    """
    return f"network.setOptions({_build_physics_options_dict(s)});"


# Prüfe ob QWebEngineView verfügbar ist
try:
    from PySide6.QtWebEngineWidgets import QWebEngineView

    HAS_WEBENGINE = True
except ImportError:
    HAS_WEBENGINE = False
    logger.warning("PySide6-WebEngine nicht verfügbar — Netzwerkgraph-Tab deaktiviert")


class XslDependencyDialog(IconRefreshMixin, QDialog):
    """Dialog zur Anzeige des vollständigen XSL-Abhängigkeitsgraphen."""

    def __init__(
        self,
        parent,
        xsl_root_dir: Path,
        dependency_graph: XslDependencyGraph,
        project_xsl_paths: set[Path] | None = None,
    ):
        """
        Args:
            parent: Eltern-Widget
            xsl_root_dir: Wurzelverzeichnis der XSL-Dateien
            dependency_graph: XSL-Abhängigkeitsgraph-Instanz
            project_xsl_paths: Absolute Pfade aller im Projekt referenzierten XSL-Dateien (None = kein Projekt)
        """
        super().__init__(parent)
        self.setWindowFlags(self.windowFlags() | Qt.WindowMinMaxButtonsHint)
        self.xsl_root_dir = xsl_root_dir
        self.dependency_graph = dependency_graph
        self._project_xsl_paths: set[Path] = project_xsl_paths if project_xsl_paths is not None else set()
        self._web_view: "QWebEngineView | None" = None
        self._graph_loaded = False
        self._temp_html_file: tempfile.NamedTemporaryFile | None = None

        self._sidebar_visible = False

        self.ui = Ui_XslDependencyDialog()
        self.ui.setupUi(self)

        # Sidebar initial ausblenden
        self.ui.sidebarWidget.setVisible(False)
        self.ui.mainSplitter.setSizes([1000, 0])
        self.ui.settingsButton.setIcon(icon("sliders"))
        self.ui.settingsButton.clicked.connect(self._toggle_sidebar)

        # Netzwerkgraph-Tab ausblenden wenn WebEngine nicht verfügbar
        if not HAS_WEBENGINE:
            self.ui.tabWidget.removeTab(1)

        # Spalten konfigurieren
        self.ui.fileTree.setHeaderLabels(["XSL-Datei", "Imports", "Importiert von"])
        self.ui.fileTree.setColumnWidth(0, 300)
        self.ui.fileTree.setColumnWidth(1, 60)
        self.ui.fileTree.setColumnWidth(2, 60)

        self.ui.depTree.setHeaderLabels(["Datei", "Typ"])
        self.ui.depTree.setColumnWidth(0, 350)

        # Graph aufbauen und anzeigen
        self._full_graph = self.dependency_graph.build_full_graph(self.xsl_root_dir)
        self._reverse_map = self._build_reverse_map()
        self._populate_file_tree()

        # Signale verbinden
        self.ui.fileTree.currentItemChanged.connect(self._on_file_selected)
        self.ui.searchEdit.textChanged.connect(self._on_search_changed)
        self.ui.tabWidget.currentChanged.connect(self._on_tab_changed)
        self.ui.graphFilterConnectedCheck.toggled.connect(lambda: self._on_search_changed(self.ui.searchEdit.text()))

        # Layout-Controls in graphSettingsPage einfügen und Einstellungen wiederherstellen
        self._build_layout_controls()
        self._restore_graph_layout_settings()

        # Ab hier auf Theme-Wechsel reagieren (siehe IconRefreshMixin)
        self.mark_icons_ready()

    def _setup_icons(self):
        """Färbt alle Icons gemäß aktuellem Theme neu ein (Button und Baum-Icons)."""
        self.ui.settingsButton.setIcon(icon("sliders"))

        # Datei-Baum neu einfärben, dabei die aktuelle Auswahl erhalten
        current = self.ui.fileTree.currentItem()
        current_path = current.data(0, Qt.ItemDataRole.UserRole) if current else None
        self._populate_file_tree()
        if current_path is not None:
            for i in range(self.ui.fileTree.topLevelItemCount()):
                item = self.ui.fileTree.topLevelItem(i)
                if item.data(0, Qt.ItemDataRole.UserRole) == current_path:
                    # Setzt die Auswahl → _on_file_selected zeichnet den Abhängigkeitsbaum neu ein
                    self.ui.fileTree.setCurrentItem(item)
                    break

    def reject(self):
        """Speichert Layout-Einstellungen und räumt temporäre Dateien und QWebEngineView auf."""
        self._save_graph_layout_settings()
        if self._web_view is not None:
            self._web_view.setUrl(QUrl("about:blank"))
            self._web_view.deleteLater()
            self._web_view = None
        if self._temp_html_file is not None:
            try:
                Path(self._temp_html_file.name).unlink(missing_ok=True)
            except Exception:
                pass
            self._temp_html_file = None
        super().reject()

    def _build_reverse_map(self) -> dict[Path, set[Path]]:
        """Baut eine Reverse-Map auf: Welche Dateien importieren eine gegebene Datei?"""
        reverse: dict[Path, set[Path]] = {}
        for xsl_file, deps in self._full_graph.items():
            for dep in deps:
                if dep not in reverse:
                    reverse[dep] = set()
                reverse[dep].add(xsl_file)
        return reverse

    def _rel_path(self, abs_path: Path) -> str:
        """Gibt den relativen Pfad zur XSL-Root zurück."""
        try:
            return str(abs_path.relative_to(self.xsl_root_dir))
        except ValueError:
            return abs_path.name

    def _populate_file_tree(self):
        """Befüllt den Dateibaum mit allen XSL-Dateien und Abhängigkeitszahlen."""
        self.ui.fileTree.clear()

        xsl_icon = icon("file-text")

        for xsl_file in sorted(self._full_graph.keys(), key=lambda p: self._rel_path(p).lower()):
            deps_count = len(self._full_graph.get(xsl_file, set()))
            reverse_count = len(self._reverse_map.get(xsl_file, set()))

            item = QTreeWidgetItem()
            item.setText(0, self._rel_path(xsl_file))
            item.setText(1, str(deps_count) if deps_count > 0 else "")
            item.setText(2, str(reverse_count) if reverse_count > 0 else "")
            item.setData(0, Qt.ItemDataRole.UserRole, xsl_file)
            item.setIcon(0, xsl_icon)

            # Hervorhebung für Dateien mit vielen Abhängigkeiten
            if deps_count > 5 or reverse_count > 10:
                font = item.font(0)
                font.setBold(True)
                item.setFont(0, font)

            self.ui.fileTree.addTopLevelItem(item)

        total = len(self._full_graph)
        with_deps = sum(1 for deps in self._full_graph.values() if deps)
        self.ui.statusLabel.setText(f"{total} XSL-Dateien, davon {with_deps} mit Abhängigkeiten")

    def _on_file_selected(self, current: QTreeWidgetItem | None, _previous: QTreeWidgetItem | None):
        """Zeigt Abhängigkeitsdetails für die ausgewählte Datei."""
        self.ui.depTree.clear()

        if current is None:
            self.ui.rightLabel.setText("Abhängigkeiten")
            return

        xsl_file: Path = current.data(0, Qt.ItemDataRole.UserRole)
        if xsl_file is None:
            return

        rel_name = self._rel_path(xsl_file)
        self.ui.rightLabel.setText(f"Abhängigkeiten: {rel_name}")

        import_icon = icon("chevron-down")
        imported_by_icon = icon("chevron-up")

        # Sektion: "Importiert" (forward dependencies)
        deps = self._full_graph.get(xsl_file, set())
        if deps:
            imports_root = QTreeWidgetItem()
            imports_root.setText(0, f"Importiert ({len(deps)})")
            imports_root.setIcon(0, import_icon)
            font = imports_root.font(0)
            font.setBold(True)
            imports_root.setFont(0, font)

            for dep in sorted(deps, key=lambda p: self._rel_path(p).lower()):
                dep_item = QTreeWidgetItem()
                dep_item.setText(0, self._rel_path(dep))
                dep_item.setText(1, "import/include")
                dep_item.setData(0, Qt.ItemDataRole.UserRole, dep)
                imports_root.addChild(dep_item)

            self.ui.depTree.addTopLevelItem(imports_root)
            imports_root.setExpanded(True)

        # Sektion: "Wird importiert von" (reverse dependencies)
        reverse_deps = self._reverse_map.get(xsl_file, set())
        if reverse_deps:
            imported_by_root = QTreeWidgetItem()
            imported_by_root.setText(0, f"Wird importiert von ({len(reverse_deps)})")
            imported_by_root.setIcon(0, imported_by_icon)
            font = imported_by_root.font(0)
            font.setBold(True)
            imported_by_root.setFont(0, font)

            for rev in sorted(reverse_deps, key=lambda p: self._rel_path(p).lower()):
                rev_item = QTreeWidgetItem()
                rev_item.setText(0, self._rel_path(rev))
                rev_item.setText(1, "importiert diese Datei")
                rev_item.setData(0, Qt.ItemDataRole.UserRole, rev)
                imported_by_root.addChild(rev_item)

            self.ui.depTree.addTopLevelItem(imported_by_root)
            imported_by_root.setExpanded(True)

        if not deps and not reverse_deps:
            no_deps_item = QTreeWidgetItem()
            no_deps_item.setText(0, "Keine Abhängigkeiten")
            self.ui.depTree.addTopLevelItem(no_deps_item)

    def _on_search_changed(self, text: str):
        """Filtert je nach aktivem Tab die Dateiliste oder den Netzwerkgraph."""
        current_tab = self.ui.tabWidget.currentIndex()
        if current_tab == 0:
            self._search_tree(text)
        elif current_tab == 1 and self._web_view is not None:
            self._search_graph(text)

    def _search_tree(self, text: str):
        """Filtert die Dateiliste nach dem Suchbegriff."""
        search_lower = text.lower()
        for i in range(self.ui.fileTree.topLevelItemCount()):
            item = self.ui.fileTree.topLevelItem(i)
            matches = not search_lower or search_lower in item.text(0).lower()
            item.setHidden(not matches)

    def _search_graph(self, text: str):
        """Filtert oder dimmt Knoten im Netzwerkgraph je nach Checkbox-Einstellung."""
        escaped = text.replace("\\", "\\\\").replace("'", "\\'")
        hide_unrelated = str(self.ui.graphFilterConnectedCheck.isChecked()).lower()
        self._web_view.page().runJavaScript(f"filterNodes('{escaped}', {hide_unrelated})")

    # === Sidebar ===

    def _toggle_sidebar(self):
        """Blendet die Einstellungs-Sidebar ein oder aus."""
        self._sidebar_visible = not self._sidebar_visible
        if self._sidebar_visible:
            self.ui.sidebarWidget.setVisible(True)
            self.ui.mainSplitter.setSizes([750, 250])
        else:
            self.ui.mainSplitter.setSizes([1000, 0])
            self.ui.sidebarWidget.setVisible(False)

    # === Graph-Layout-Einstellungen ===

    def _build_layout_controls(self):
        """Erstellt programmatisch alle Layout-Steuerelemente in graphSettingsPage."""
        layout = self.ui.graphSettingsLayout

        # Spacer am Ende entfernen (wird am Schluss wieder angehängt)
        layout.removeItem(self.ui.graphSettingsSpacer)

        # ComboBox für Layout-Auswahl
        layout.addWidget(QLabel("Layout-Algorithmus:"))

        self._layout_combo = QComboBox()
        self._layout_combo.addItem("Barnes-Hut", GraphLayout.BARNES_HUT)
        self._layout_combo.addItem("ForceAtlas2", GraphLayout.FORCE_ATLAS2)
        self._layout_combo.addItem("Repulsion", GraphLayout.REPULSION)
        self._layout_combo.addItem("Hierarchisch", GraphLayout.HIERARCHICAL)
        layout.addWidget(self._layout_combo)

        # StackedWidget für layout-spezifische Parameter
        self._params_stack = QStackedWidget()
        layout.addWidget(self._params_stack)

        # Seite 0: barnesHut
        self._params_stack.addWidget(self._build_barnes_hut_page())

        # Seite 1: forceAtlas2Based
        self._params_stack.addWidget(self._build_force_atlas2_page())

        # Seite 2: repulsion
        self._params_stack.addWidget(self._build_repulsion_page())

        # Seite 3: hierarchical
        self._params_stack.addWidget(self._build_hierarchical_page())

        # Spacer wieder an den Schluss
        layout.addItem(self.ui.graphSettingsSpacer)

        # Signale verbinden — live Update bei jeder Änderung
        self._layout_combo.currentIndexChanged.connect(self._on_layout_changed)
        for spinbox in (
            self._bh_gravitational,
            self._bh_spring_length,
            self._fa_gravitational,
            self._fa_spring_length,
            self._re_node_distance,
            self._re_spring_length,
            self._hi_level_separation,
            self._hi_node_spacing,
            self._hi_tree_spacing,
        ):
            spinbox.valueChanged.connect(self._apply_layout_to_graph)
        for dspinbox in (
            self._bh_central_gravity,
            self._bh_spring_constant,
            self._bh_damping,
            self._fa_central_gravity,
            self._fa_spring_constant,
            self._fa_damping,
            self._re_central_gravity,
            self._re_spring_constant,
            self._re_damping,
        ):
            dspinbox.valueChanged.connect(self._apply_layout_to_graph)
        self._hi_direction.currentIndexChanged.connect(self._apply_layout_to_graph)
        self._hi_sort_method.currentIndexChanged.connect(self._apply_layout_to_graph)

    def _build_barnes_hut_page(self) -> QWidget:
        """Erstellt die Parameter-Seite für das Barnes-Hut-Layout."""
        page = QWidget()
        form = QFormLayout(page)
        self._bh_gravitational = QSpinBox()
        self._bh_gravitational.setRange(-30000, 0)
        self._bh_gravitational.setSingleStep(100)
        self._bh_central_gravity = QDoubleSpinBox()
        self._bh_central_gravity.setRange(0.0, 5.0)
        self._bh_central_gravity.setSingleStep(0.1)
        self._bh_central_gravity.setDecimals(2)
        self._bh_spring_length = QSpinBox()
        self._bh_spring_length.setRange(1, 1000)
        self._bh_spring_constant = QDoubleSpinBox()
        self._bh_spring_constant.setRange(0.0, 1.0)
        self._bh_spring_constant.setSingleStep(0.01)
        self._bh_spring_constant.setDecimals(3)
        self._bh_damping = QDoubleSpinBox()
        self._bh_damping.setRange(0.0, 1.0)
        self._bh_damping.setSingleStep(0.01)
        self._bh_damping.setDecimals(2)
        form.addRow("Gravitation:", self._bh_gravitational)
        form.addRow("Zentrale Gravitation:", self._bh_central_gravity)
        form.addRow("Federlänge:", self._bh_spring_length)
        form.addRow("Federkonstante:", self._bh_spring_constant)
        form.addRow("Dämpfung:", self._bh_damping)
        return page

    def _build_force_atlas2_page(self) -> QWidget:
        """Erstellt die Parameter-Seite für das ForceAtlas2-Layout."""
        page = QWidget()
        form = QFormLayout(page)
        self._fa_gravitational = QSpinBox()
        self._fa_gravitational.setRange(-10000, 0)
        self._fa_gravitational.setSingleStep(10)
        self._fa_central_gravity = QDoubleSpinBox()
        self._fa_central_gravity.setRange(0.0, 5.0)
        self._fa_central_gravity.setSingleStep(0.01)
        self._fa_central_gravity.setDecimals(3)
        self._fa_spring_length = QSpinBox()
        self._fa_spring_length.setRange(1, 1000)
        self._fa_spring_constant = QDoubleSpinBox()
        self._fa_spring_constant.setRange(0.0, 1.0)
        self._fa_spring_constant.setSingleStep(0.01)
        self._fa_spring_constant.setDecimals(3)
        self._fa_damping = QDoubleSpinBox()
        self._fa_damping.setRange(0.0, 1.0)
        self._fa_damping.setSingleStep(0.01)
        self._fa_damping.setDecimals(2)
        form.addRow("Gravitation:", self._fa_gravitational)
        form.addRow("Zentrale Gravitation:", self._fa_central_gravity)
        form.addRow("Federlänge:", self._fa_spring_length)
        form.addRow("Federkonstante:", self._fa_spring_constant)
        form.addRow("Dämpfung:", self._fa_damping)
        return page

    def _build_repulsion_page(self) -> QWidget:
        """Erstellt die Parameter-Seite für das Repulsion-Layout."""
        page = QWidget()
        form = QFormLayout(page)
        self._re_node_distance = QSpinBox()
        self._re_node_distance.setRange(1, 1000)
        self._re_central_gravity = QDoubleSpinBox()
        self._re_central_gravity.setRange(0.0, 5.0)
        self._re_central_gravity.setSingleStep(0.1)
        self._re_central_gravity.setDecimals(2)
        self._re_spring_length = QSpinBox()
        self._re_spring_length.setRange(1, 1000)
        self._re_spring_constant = QDoubleSpinBox()
        self._re_spring_constant.setRange(0.0, 1.0)
        self._re_spring_constant.setSingleStep(0.01)
        self._re_spring_constant.setDecimals(3)
        self._re_damping = QDoubleSpinBox()
        self._re_damping.setRange(0.0, 1.0)
        self._re_damping.setSingleStep(0.01)
        self._re_damping.setDecimals(2)
        form.addRow("Knotenabstand:", self._re_node_distance)
        form.addRow("Zentrale Gravitation:", self._re_central_gravity)
        form.addRow("Federlänge:", self._re_spring_length)
        form.addRow("Federkonstante:", self._re_spring_constant)
        form.addRow("Dämpfung:", self._re_damping)
        return page

    def _build_hierarchical_page(self) -> QWidget:
        """Erstellt die Parameter-Seite für das hierarchische Layout."""
        page = QWidget()
        form = QFormLayout(page)
        self._hi_direction = QComboBox()
        self._hi_direction.addItem("Oben → Unten", HierarchicalDirection.UD)
        self._hi_direction.addItem("Unten → Oben", HierarchicalDirection.DU)
        self._hi_direction.addItem("Links → Rechts", HierarchicalDirection.LR)
        self._hi_direction.addItem("Rechts → Links", HierarchicalDirection.RL)
        self._hi_sort_method = QComboBox()
        self._hi_sort_method.addItem("Hubsize", HierarchicalSortMethod.HUBSIZE)
        self._hi_sort_method.addItem("Gerichtet", HierarchicalSortMethod.DIRECTED)
        self._hi_level_separation = QSpinBox()
        self._hi_level_separation.setRange(1, 1000)
        self._hi_node_spacing = QSpinBox()
        self._hi_node_spacing.setRange(1, 1000)
        self._hi_tree_spacing = QSpinBox()
        self._hi_tree_spacing.setRange(1, 1000)
        form.addRow("Richtung:", self._hi_direction)
        form.addRow("Sortierung:", self._hi_sort_method)
        form.addRow("Ebenenabstand:", self._hi_level_separation)
        form.addRow("Knotenabstand:", self._hi_node_spacing)
        form.addRow("Baumabstand:", self._hi_tree_spacing)
        return page

    def _on_layout_changed(self, index: int):
        """Wechselt die sichtbare Parameter-Seite und wendet das Layout sofort an."""
        self._params_stack.setCurrentIndex(index)
        self._apply_layout_to_graph()

    def _current_layout_settings(self) -> GraphLayoutSettings:
        """Liest alle aktuellen Steuerelement-Werte aus und gibt ein GraphLayoutSettings-Objekt zurück."""
        return GraphLayoutSettings(
            layout=self._layout_combo.currentData(),
            bh_gravitational_constant=self._bh_gravitational.value(),
            bh_central_gravity=self._bh_central_gravity.value(),
            bh_spring_length=self._bh_spring_length.value(),
            bh_spring_constant=self._bh_spring_constant.value(),
            bh_damping=self._bh_damping.value(),
            fa_gravitational_constant=self._fa_gravitational.value(),
            fa_central_gravity=self._fa_central_gravity.value(),
            fa_spring_length=self._fa_spring_length.value(),
            fa_spring_constant=self._fa_spring_constant.value(),
            fa_damping=self._fa_damping.value(),
            re_node_distance=self._re_node_distance.value(),
            re_central_gravity=self._re_central_gravity.value(),
            re_spring_length=self._re_spring_length.value(),
            re_spring_constant=self._re_spring_constant.value(),
            re_damping=self._re_damping.value(),
            hi_direction=self._hi_direction.currentData(),
            hi_sort_method=self._hi_sort_method.currentData(),
            hi_level_separation=self._hi_level_separation.value(),
            hi_node_spacing=self._hi_node_spacing.value(),
            hi_tree_spacing=self._hi_tree_spacing.value(),
        )

    def _restore_graph_layout_settings(self):
        """Stellt die gespeicherten Layout-Einstellungen aus app_settings wieder her."""
        s = app_settings.graph_layout_settings

        # ComboBox: Layout auswählen (Signale blockieren, um Mehrfach-Updates zu vermeiden)
        self._layout_combo.blockSignals(True)
        for i in range(self._layout_combo.count()):
            if self._layout_combo.itemData(i) == s.layout:
                self._layout_combo.setCurrentIndex(i)
                self._params_stack.setCurrentIndex(i)
                break
        self._layout_combo.blockSignals(False)

        # barnesHut
        self._bh_gravitational.setValue(s.bh_gravitational_constant)
        self._bh_central_gravity.setValue(s.bh_central_gravity)
        self._bh_spring_length.setValue(s.bh_spring_length)
        self._bh_spring_constant.setValue(s.bh_spring_constant)
        self._bh_damping.setValue(s.bh_damping)

        # forceAtlas2Based
        self._fa_gravitational.setValue(s.fa_gravitational_constant)
        self._fa_central_gravity.setValue(s.fa_central_gravity)
        self._fa_spring_length.setValue(s.fa_spring_length)
        self._fa_spring_constant.setValue(s.fa_spring_constant)
        self._fa_damping.setValue(s.fa_damping)

        # repulsion
        self._re_node_distance.setValue(s.re_node_distance)
        self._re_central_gravity.setValue(s.re_central_gravity)
        self._re_spring_length.setValue(s.re_spring_length)
        self._re_spring_constant.setValue(s.re_spring_constant)
        self._re_damping.setValue(s.re_damping)

        # hierarchical
        for i in range(self._hi_direction.count()):
            if self._hi_direction.itemData(i) == s.hi_direction:
                self._hi_direction.setCurrentIndex(i)
                break
        for i in range(self._hi_sort_method.count()):
            if self._hi_sort_method.itemData(i) == s.hi_sort_method:
                self._hi_sort_method.setCurrentIndex(i)
                break
        self._hi_level_separation.setValue(s.hi_level_separation)
        self._hi_node_spacing.setValue(s.hi_node_spacing)
        self._hi_tree_spacing.setValue(s.hi_tree_spacing)

        logger.debug("Graph-Layout-Einstellungen wiederhergestellt: %s", s.layout)

    def _apply_layout_to_graph(self):
        """Überträgt die aktuellen Layout-Einstellungen per JavaScript an den vis.js Graph."""
        if not self._graph_loaded or self._web_view is None:
            return

        s = self._current_layout_settings()
        self._web_view.page().runJavaScript(_build_set_options_js(s))
        logger.debug("Layout angewendet: %s", s.layout)

    def _save_graph_layout_settings(self):
        """Speichert die aktuellen Layout-Einstellungen in app_settings und persistiert sie."""
        app_settings.graph_layout_settings = self._current_layout_settings()
        app_settings.save()
        logger.info("Graph-Layout-Einstellungen gespeichert: %s", app_settings.graph_layout_settings.layout)

    # === Netzwerkgraph (vis.js) ===

    def _on_tab_changed(self, index: int):
        """Lazy-Init des Netzwerkgraphs beim ersten Tab-Wechsel und Sidebar-Seite synchronisieren."""
        # Sidebar-Seite synchronisieren
        self.ui.settingsStack.setCurrentIndex(index)

        if not HAS_WEBENGINE:
            return

        # Tab 1 = Netzwerkgraph
        if index == 1 and not self._graph_loaded:
            self._setup_network_graph()

        # Suchbegriff auf den neuen Tab anwenden
        search_text = self.ui.searchEdit.text()
        if search_text:
            self._on_search_changed(search_text)

    def _setup_network_graph(self):
        """Erstellt QWebEngineView und lädt den vis.js Netzwerkgraph."""
        self._web_view = QWebEngineView(self.ui.graphContainer)
        self.ui.graphContainerLayout.addWidget(self._web_view)

        # Graph-Daten aufbauen (nur direkte Abhängigkeiten für Kanten)
        nodes_json, edges_json = self._build_graph_data()
        html = self._generate_html(nodes_json, edges_json)

        # HTML in temporäre Datei schreiben und per URL laden
        # (setHtml() hat Größenlimit und kann bei großen Inhalten einfrieren)
        self._temp_html_file = tempfile.NamedTemporaryFile(mode="w", suffix=".html", encoding="utf-8", delete=False)
        self._temp_html_file.write(html)
        self._temp_html_file.close()
        self._web_view.loadFinished.connect(self._on_graph_load_finished)
        self._web_view.page().titleChanged.connect(self._on_graph_title_changed)
        self._web_view.setUrl(QUrl.fromLocalFile(self._temp_html_file.name))

    def _on_graph_load_finished(self, ok: bool):
        """Wird aufgerufen, wenn die vis.js-Seite vollständig geladen ist."""
        if ok:
            self._graph_loaded = True
            logger.info("Netzwerkgraph vollständig geladen")
        else:
            logger.error("Netzwerkgraph konnte nicht geladen werden")

    def _on_graph_title_changed(self, title: str):
        """Reagiert auf Doppelklick im Netzwerkgraph: Kopiert Knotenname ins Suchfeld."""
        prefix = "nodeSearch:"
        if title.startswith(prefix):
            node_label = title[len(prefix) :]
            self.ui.searchEdit.setText(node_label)

    def _build_graph_data(self) -> tuple[str, str]:
        """
        Konvertiert den Abhängigkeitsgraph in vis.js-kompatible JSON-Strukturen.

        Knoten werden in drei Kategorien eingeteilt:
        - Kategorie 1 (blau): Nur im Verzeichnis, nicht im Projekt
        - Kategorie 2 (grün): Im Projekt und im Verzeichnis
        - Kategorie 3 (rot): Im Projekt referenziert, aber Datei fehlt

        Returns:
            tuple[str, str]: (nodes_json, edges_json)
        """
        # Direkten Graph verwenden (nicht transitiv)
        direct_graph = self.dependency_graph.build_direct_graph(self.xsl_root_dir)

        # Dateisystem-Pfade (Kategorie 1 und 2)
        fs_paths = sorted(direct_graph.keys(), key=lambda p: self._rel_path(p).lower())
        path_to_id: dict[Path, int] = {path: idx for idx, path in enumerate(fs_paths)}

        # Kategorie 3: Im Projekt referenziert, aber nicht im Dateisystem
        ghost_paths = sorted(
            self._project_xsl_paths - set(direct_graph.keys()),
            key=lambda p: p.name.lower(),
        )
        ghost_offset = len(fs_paths)
        for idx, path in enumerate(ghost_paths):
            path_to_id[path] = ghost_offset + idx

        # Farb-Definitionen pro Kategorie
        color_fs_only = {"background": "#4a90d9", "border": "#2c5f9e"}  # Kat. 1: nur Verzeichnis
        color_in_project = {"background": "#4caf50", "border": "#2e7d32"}  # Kat. 2: Projekt + Verzeichnis
        color_ghost = {"background": "#e74c3c", "border": "#c0392b"}  # Kat. 3: Projekt, Datei fehlt

        nodes = []

        # Kategorie 1 und 2: Dateien die im Verzeichnis existieren
        for path in fs_paths:
            node_id = path_to_id[path]
            rel = self._rel_path(path)
            label = path.name
            direct_deps = len(direct_graph.get(path, set()))
            reverse_count = len(self._reverse_map.get(path, set()))
            value = max(direct_deps + reverse_count, 1)
            in_project = path in self._project_xsl_paths

            title = (
                f"<b>{rel}</b><br>"
                f"Importiert: {direct_deps}<br>"
                f"Importiert von: {reverse_count}<br>"
                f"Im Projekt: {'Ja' if in_project else 'Nein'}"
            )

            node: dict = {
                "id": node_id,
                "label": label,
                "title": title,
                "value": value,
                "color": color_in_project if in_project else color_fs_only,
                "borderWidth": 3 if in_project else 1,
            }
            nodes.append(node)

        # Kategorie 3: Ghost-Knoten (im Projekt, aber Datei fehlt im Verzeichnis)
        for path in ghost_paths:
            rel = self._rel_path(path)
            title = f"<b>{rel}</b><br><i>Datei nicht gefunden</i><br>Im Projekt: Ja"
            nodes.append(
                {
                    "id": path_to_id[path],
                    "label": path.name,
                    "title": title,
                    "value": 1,
                    "color": color_ghost,
                    "borderWidth": 2,
                    "shapeProperties": {"borderDashes": [5, 5]},
                }
            )

        # Edges (nur zwischen Dateisystem-Knoten — Ghost-Knoten haben keine bekannten Abhängigkeiten)
        edges = []
        for path, deps in direct_graph.items():
            from_id = path_to_id.get(path)
            if from_id is None:
                continue
            for dep in deps:
                to_id = path_to_id.get(dep)
                if to_id is not None:
                    edges.append({"from": from_id, "to": to_id})

        return json.dumps(nodes, ensure_ascii=False), json.dumps(edges, ensure_ascii=False)

    def _generate_html(self, nodes_json: str, edges_json: str) -> str:
        """
        Generiert die HTML-Seite mit inline vis.js und Graph-Daten.

        Args:
            nodes_json: vis.js Nodes als JSON-String
            edges_json: vis.js Edges als JSON-String

        Returns:
            str: Vollständige HTML-Seite
        """
        # vis.js Bibliothek einlesen
        vis_js_path = Path(__file__).parent.parent / "res" / "vis-network.min.js"
        try:
            vis_js_content = vis_js_path.read_text(encoding="utf-8")
        except Exception as e:
            logger.error(f"Konnte vis-network.min.js nicht laden: {e}")
            return f"<html><body><h2>Fehler: vis-network.min.js nicht gefunden</h2><p>{e}</p></body></html>"

        # Hintergrundfarbe aus Qt-Palette ableiten
        palette = self.palette()
        bg_color = palette.window().color()
        text_color = palette.windowText().color()
        bg_hex = bg_color.name()
        text_hex = text_color.name()

        return f"""<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
    html, body {{
        margin: 0;
        padding: 0;
        width: 100%;
        height: 100%;
        overflow: hidden;
        background-color: {bg_hex};
        color: {text_hex};
        font-family: sans-serif;
    }}
    #graph-container {{
        width: 100%;
        height: 100%;
    }}
    .vis-tooltip {{
        background-color: {bg_hex} !important;
        color: {text_hex} !important;
        border: 1px solid #888 !important;
        border-radius: 4px !important;
        padding: 8px !important;
        font-size: 13px !important;
        box-shadow: 2px 2px 6px rgba(0,0,0,0.3) !important;
    }}
    #graph-legend {{
        position: absolute;
        top: 16px;
        left: 16px;
        background: {bg_hex}cc;
        border: 1px solid #888;
        border-radius: 6px;
        padding: 10px 14px;
        font-size: 12px;
        color: {text_hex};
        z-index: 10;
        line-height: 1.8;
        pointer-events: none;
    }}
    .legend-dot {{
        display: inline-block;
        width: 12px;
        height: 12px;
        border-radius: 50%;
        margin-right: 6px;
        vertical-align: middle;
    }}
</style>
<script type="text/javascript">
{vis_js_content}
</script>
</head>
<body>
<div id="graph-container"></div>
<div id="graph-legend">
    <span class="legend-dot" style="background:#4a90d9;border:2px solid #2c5f9e;"></span>Nur im Verzeichnis<br>
    <span class="legend-dot" style="background:#4caf50;border:3px solid #2e7d32;"></span>Im Projekt &amp; Verzeichnis<br>
    <span class="legend-dot" style="background:#e74c3c;border:2px dashed #c0392b;"></span>Im Projekt, Datei fehlt
</div>
<script type="text/javascript">
    var nodesData = {nodes_json};
    var edgesData = {edges_json};

    // Titles von String zu DOM-Elementen konvertieren (vis.js rendert nur DOM-Elemente als HTML)
    nodesData.forEach(function(node) {{
        if (node.title) {{
            var el = document.createElement('div');
            el.innerHTML = node.title;
            node.title = el;
        }}
    }});

    var nodes = new vis.DataSet(nodesData);
    var edges = new vis.DataSet(edgesData);

    var container = document.getElementById('graph-container');
    var data = {{ nodes: nodes, edges: edges }};

    var options = {{
        nodes: {{
            shape: 'dot',
            scaling: {{
                min: 8,
                max: 28,
                label: {{ enabled: true, min: 10, max: 16 }}
            }},
            font: {{
                size: 12,
                color: '{text_hex}',
                strokeWidth: 2,
                strokeColor: '{bg_hex}'
            }},
            color: {{
                background: '#4a90d9',
                border: '#2c5f9e',
                highlight: {{ background: '#ff8c00', border: '#cc7000' }},
                hover: {{ background: '#5da0e9', border: '#3a6fae' }}
            }}
        }},
        edges: {{
            arrows: {{ to: {{ enabled: true, scaleFactor: 0.5 }} }},
            color: {{
                color: '#888888',
                highlight: '#ff8c00',
                hover: '#aaaaaa',
                opacity: 0.7
            }},
            smooth: {{ type: 'continuous' }}
        }},
        interaction: {{
            hover: true,
            tooltipDelay: 200,
            navigationButtons: true,
            keyboard: true
        }}
    }};

    // Layout-Einstellungen VOR Netzwerk-Erstellung in options einmischen
    Object.assign(options, {_build_physics_options_dict(app_settings.graph_layout_settings)});

    var network = new vis.Network(container, data, options);

    // Nachbar-Hervorhebung bei Klick
    var allNodes = nodes.get();
    var originalColors = {{}};
    allNodes.forEach(function(node) {{
        originalColors[node.id] = {{
            color: node.color || options.nodes.color,
            font: node.font || options.nodes.font
        }};
    }});

    network.on("selectNode", function(params) {{
        var selectedId = params.nodes[0];
        var connectedNodes = network.getConnectedNodes(selectedId);
        var connectedSet = new Set(connectedNodes);
        connectedSet.add(selectedId);

        var updates = [];
        allNodes.forEach(function(node) {{
            if (connectedSet.has(node.id)) {{
                updates.push({{
                    id: node.id,
                    opacity: 1.0
                }});
            }} else {{
                updates.push({{
                    id: node.id,
                    opacity: 0.15
                }});
            }}
        }});
        nodes.update(updates);

        // Kanten dimmen
        var allEdges = edges.get();
        var connectedEdges = network.getConnectedEdges(selectedId);
        var connectedEdgeSet = new Set(connectedEdges);
        var edgeUpdates = [];
        allEdges.forEach(function(edge) {{
            if (connectedEdgeSet.has(edge.id)) {{
                edgeUpdates.push({{ id: edge.id, color: {{ opacity: 1.0 }} }});
            }} else {{
                edgeUpdates.push({{ id: edge.id, color: {{ opacity: 0.08 }} }});
            }}
        }});
        edges.update(edgeUpdates);
    }});

    network.on("doubleClick", function(params) {{
        if (params.nodes.length > 0) {{
            var nodeId = params.nodes[0];
            var node = nodes.get(nodeId);
            if (node) {{
                document.title = "nodeSearch:" + node.label;
            }}
        }}
    }});

    network.on("deselectNode", function() {{
        // Alle Knoten zurücksetzen
        var updates = [];
        allNodes.forEach(function(node) {{
            updates.push({{
                id: node.id,
                opacity: 1.0
            }});
        }});
        nodes.update(updates);

        // Alle Kanten zurücksetzen
        var allEdges = edges.get();
        var edgeUpdates = [];
        allEdges.forEach(function(edge) {{
            edgeUpdates.push({{ id: edge.id, color: {{ opacity: 0.7 }} }});
        }});
        edges.update(edgeUpdates);
    }});

    // Suchfunktion: Knoten nach Label filtern
    function filterNodes(searchTerm, hideUnrelated) {{
        var term = searchTerm.toLowerCase();

        if (hideUnrelated) {{
            // Originalzustand wiederherstellen
            nodes.clear();
            edges.clear();
            nodes.add(nodesData);
            edges.add(edgesData);
            allNodes = nodes.get();

            if (!term) {{
                network.fit({{ animation: {{ duration: 400, easingFunction: 'easeInOutQuad' }} }});
                return;
            }}

            // 1. Matching-Knoten finden
            var matchedIds = new Set();
            nodesData.forEach(function(node) {{
                if (node.label.toLowerCase().indexOf(term) !== -1) {{
                    matchedIds.add(node.id);
                }}
            }});

            // 2. Gerichtete Adjazenzlisten aufbauen
            var fwd = {{}};  // from → to (Import-Richtung)
            var bwd = {{}};  // to → from (Reverse)
            edgesData.forEach(function(edge) {{
                if (!fwd[edge.from]) fwd[edge.from] = [];
                if (!bwd[edge.to]) bwd[edge.to] = [];
                fwd[edge.from].push(edge.to);
                bwd[edge.to].push(edge.from);
            }});

            // 3. BFS vorwärts (Nachkommen) + rückwärts (Vorfahren)
            var reachable = new Set(matchedIds);
            var queue = Array.from(matchedIds);
            while (queue.length > 0) {{
                var current = queue.shift();
                (fwd[current] || []).forEach(function(n) {{
                    if (!reachable.has(n)) {{ reachable.add(n); queue.push(n); }}
                }});
            }}
            queue = Array.from(matchedIds);
            while (queue.length > 0) {{
                var current = queue.shift();
                (bwd[current] || []).forEach(function(n) {{
                    if (!reachable.has(n)) {{ reachable.add(n); queue.push(n); }}
                }});
            }}

            // 4. Nicht-erreichbare Knoten und Kanten entfernen
            var removeNodeIds = [];
            nodesData.forEach(function(node) {{
                if (!reachable.has(node.id)) removeNodeIds.push(node.id);
            }});
            nodes.remove(removeNodeIds);

            var removeEdgeIds = [];
            edgesData.forEach(function(edge) {{
                if (!reachable.has(edge.from) || !reachable.has(edge.to))
                    removeEdgeIds.push(edge.id);
            }});
            edges.remove(removeEdgeIds);

            // Matching-Knoten hervorheben, verbundene leicht dimmen
            var updates = [];
            matchedIds.forEach(function(id) {{
                updates.push({{ id: id, opacity: 1.0 }});
            }});
            reachable.forEach(function(id) {{
                if (!matchedIds.has(id)) {{
                    updates.push({{ id: id, opacity: 0.5 }});
                }}
            }});
            nodes.update(updates);
            allNodes = nodes.get();
            network.fit({{ animation: {{ duration: 400, easingFunction: 'easeInOutQuad' }} }});

        }} else {{
            // Originalzustand wiederherstellen falls vorher gefiltert
            nodes.clear();
            edges.clear();
            nodes.add(nodesData);
            edges.add(edgesData);
            allNodes = nodes.get();

            // Bisheriges Verhalten: nur dimmen
            var updates = [];
            var matchedIds = new Set();

            allNodes.forEach(function(node) {{
                if (!term || node.label.toLowerCase().indexOf(term) !== -1) {{
                    updates.push({{ id: node.id, opacity: 1.0 }});
                    matchedIds.add(node.id);
                }} else {{
                    updates.push({{ id: node.id, opacity: 0.12 }});
                }}
            }});
            nodes.update(updates);

            var allEdges = edges.get();
            var edgeUpdates = [];
            allEdges.forEach(function(edge) {{
                if (!term || (matchedIds.has(edge.from) && matchedIds.has(edge.to))) {{
                    edgeUpdates.push({{ id: edge.id, color: {{ opacity: 0.7 }} }});
                }} else {{
                    edgeUpdates.push({{ id: edge.id, color: {{ opacity: 0.05 }} }});
                }}
            }});
            edges.update(edgeUpdates);

            if (term && matchedIds.size > 0) {{
                network.fit({{ nodes: Array.from(matchedIds), animation: {{ duration: 400, easingFunction: 'easeInOutQuad' }} }});
            }} else {{
                network.fit({{ animation: {{ duration: 400, easingFunction: 'easeInOutQuad' }} }});
            }}
        }}
    }}
</script>
</body>
</html>"""
