"""
NetWeaver analysis wizard dialog for the QGIS plugin.
"""

import os
import sqlite3
import uuid
from pathlib import Path
from typing import Any, Dict, List, Optional

from ..qt_compat import (
    QDialog,
    QAbstractItemView,
    QApplication,
    QCheckBox,
    QComboBox,
    QFileDialog,
    QFormLayout,
    QGridLayout,
    QGroupBox,
    QHBoxLayout,
    QLabel,
    QLineEdit,
    QListWidget,
    QListWidgetItem,
    QMessageBox,
    QProgressDialog,
    QPushButton,
    QRadioButton,
    QSpinBox,
    QTableWidget,
    QTableWidgetItem,
    QTextEdit,
    QTreeWidget,
    QTreeWidgetItem,
    QVBoxLayout,
    QWizard,
    QWizardPage,
    Qt,
    exec_dialog,
)

from .. import debug_logger
from ..bridge_loader import NetWeaverBridge
from ..bridge_worker import BridgeWorker
from ..models import EngineType, WorkflowStep
from ..parameter_mapper import ModelVariableExtractor
from ..project_metadata_manager import ProjectMetadataManager
from ..rich_comment_editor import RichCommentEditor
from ..translator import tr


class CreateNetWeaverAnalysisDialog(QWizard):
    """Wizard for creating and optionally running a NetWeaver analysis."""

    def __init__(
        self,
        *,
        project_id: str,
        assessment_id: str,
        input_layer_name: str,
        input_table_name: str,
        input_fields: List[str],
        engine_path: str,
        metadata_manager: Optional[ProjectMetadataManager] = None,
        database_path: str = "",
        parent=None,
    ):
        super().__init__(parent)
        self.project_id = project_id
        self.assessment_id = assessment_id
        self.input_layer_name = input_layer_name
        self.input_table_name = input_table_name
        self.input_fields = input_fields or []
        self.engine_path = engine_path
        self.metadata_manager = metadata_manager
        self.database_path = database_path or self._resolve_project_database_path()

        self.model_info: Dict[str, Any] = {}
        self.topic_tree: List[Dict[str, Any]] = []
        self.available_variables: List[str] = []
        # D2: variable name -> {"persistent_ignore": bool, "conditional_ignore": bool}
        self._variable_ignore_flags: Dict[str, Dict[str, bool]] = {}
        self.field_mappings: List[Dict[str, str]] = []
        self._result_step: Optional[WorkflowStep] = None
        self._active_workers: list = []
        self._bridge: Optional[NetWeaverBridge] = None
        self._progress_dialog: Optional[QProgressDialog] = None
        self._model_path_str: str = ""
        self._pending_mapping_payload: Optional[Dict[str, Any]] = None
        self._column_aliases: Dict[str, List[str]] = {}
        # Set when the model came from the central registry (provenance item 6):
        # the embedded+referenced model_snapshots id, so the created task is
        # linked to the verified registry version.
        self._registry_model_snapshot_id: str = ""

        self.setWindowTitle(tr("create_netweaver.window_title"))
        self.setWizardStyle(QWizard.WizardStyle.ClassicStyle)
        self.setOption(QWizard.WizardOption.NoBackButtonOnStartPage, True)
        self.setMinimumSize(900, 700)
        self.currentIdChanged.connect(self._on_page_changed)

        self._init_pages()

    def _resolve_project_database_path(self) -> str:
        """Resolve the workspace GeoPackage path from metadata when the caller did not pass it."""
        if self.metadata_manager is None:
            return ""
        try:
            project = self.metadata_manager.get_project(self.project_id)
        except Exception:
            project = None
        metadata = getattr(project, "metadata", {}) or {}
        return str(
            metadata.get("database_path")
            or metadata.get("geodatabase_connection_string")
            or metadata.get("GeoDatabaseConnectionString")
            or ""
        )

    @staticmethod
    def _normalize_alias_key(value: Any) -> str:
        import re

        text = str(value or "").strip().lower()
        text = re.sub(r"^(input|target|source)[_\s]+", "", text)
        text = re.sub(r"^[a-z0-9]+[_\s]+", "", text)
        return re.sub(r"[^a-z0-9]+", "", text)

    @staticmethod
    def _append_alias(alias_map: Dict[str, List[str]], column_name: str, alias: Any) -> None:
        alias_text = str(alias or "").strip()
        if not column_name or not alias_text:
            return
        values = alias_map.setdefault(column_name, [])
        if alias_text not in values:
            values.append(alias_text)
        lower_key = column_name.lower()
        if lower_key != column_name:
            lower_values = alias_map.setdefault(lower_key, [])
            if alias_text not in lower_values:
                lower_values.append(alias_text)

    def _load_gpkg_column_aliases(self) -> Dict[str, List[str]]:
        """Read GeoPackage data-column aliases for the current input table."""
        alias_map: Dict[str, List[str]] = {}
        if not self.database_path or not self.input_table_name:
            return alias_map
        try:
            conn = sqlite3.connect(self.database_path)
            conn.row_factory = sqlite3.Row
            rows = conn.execute(
                """
                SELECT column_name, name, title, description
                FROM gpkg_data_columns
                WHERE lower(table_name) = lower(?)
                """,
                (self.input_table_name,),
            ).fetchall()
            conn.close()
        except Exception as exc:
            debug_logger.warning("Could not load GeoPackage column aliases", {
                "database_path": self.database_path,
                "input_table_name": self.input_table_name,
                "exception": str(exc),
            })
            return alias_map

        for row in rows:
            column_name = str(row["column_name"] or "").strip()
            for key in ("name", "title", "description"):
                self._append_alias(alias_map, column_name, row[key])
        return alias_map

    def _legacy_admin_database_candidates(self) -> List[str]:
        """Return optional legacy EMDS admin database paths, when configured."""
        candidates: List[str] = []
        env_path = os.environ.get("EMDS_LEGACY_INFO_DB", "")
        if env_path:
            candidates.append(env_path)
        if self.metadata_manager is not None:
            try:
                project = self.metadata_manager.get_project(self.project_id)
                metadata = getattr(project, "metadata", {}) or {}
            except Exception:
                metadata = {}
            for key in (
                "legacy_emds_info_path",
                "legacy_admin_database_path",
                "legacy_metadata_database_path",
                "emds_info_path",
            ):
                value = str(metadata.get(key, "") or "").strip()
                if value:
                    candidates.append(value)
        seen = set()
        result: List[str] = []
        for candidate in candidates:
            normalized = str(candidate)
            if normalized.lower() in seen:
                continue
            seen.add(normalized.lower())
            if Path(normalized).exists():
                result.append(normalized)
        return result

    def _load_legacy_output_aliases(self) -> Dict[str, List[str]]:
        """
        Fold legacy OutputAliases into the physical-column alias map.

        This is intentionally opportunistic: the current QGIS project normally
        has enough alias metadata in gpkg_data_columns, but migrated/admin
        databases can provide additional model-field aliases.
        """
        alias_map: Dict[str, List[str]] = {}
        normalized_fields = {
            self._normalize_alias_key(field_name): field_name
            for field_name in self.input_fields
        }
        for database_path in self._legacy_admin_database_candidates():
            try:
                conn = sqlite3.connect(database_path)
                conn.row_factory = sqlite3.Row
                rows = conn.execute(
                    """
                    SELECT ModelFieldName, OutputAliasName, DataSourceFieldNames
                    FROM OutputAliases
                    WHERE COALESCE(IsDeleted, 0) = 0 OR COALESCE(IsCurrent, 0) IN (0, 1)
                    """
                ).fetchall()
                conn.close()
            except Exception as exc:
                debug_logger.warning("Could not load legacy OutputAliases", {
                    "database_path": database_path,
                    "exception": str(exc),
                })
                continue

            for row in rows:
                model_field = str(row["ModelFieldName"] or "").strip()
                source_names = [
                    str(row["OutputAliasName"] or "").strip(),
                    str(row["DataSourceFieldNames"] or "").strip(),
                ]
                for source_name in source_names:
                    source_key = self._normalize_alias_key(source_name)
                    column_name = normalized_fields.get(source_key)
                    if not column_name:
                        # QGIS overlay fields are usually prefixed, e.g.
                        # input_CANOPYDENS for legacy CANOPYDENS.
                        for field_key, field_name in normalized_fields.items():
                            if source_key and field_key.endswith(source_key):
                                column_name = field_name
                                break
                    if column_name:
                        self._append_alias(alias_map, column_name, model_field)
        return alias_map

    def _build_column_aliases(self) -> Dict[str, List[str]]:
        alias_map = self._load_gpkg_column_aliases()
        for column_name, aliases in self._load_legacy_output_aliases().items():
            for alias in aliases:
                self._append_alias(alias_map, column_name, alias)
        debug_logger.debug("Loaded NetWeaver input aliases", {
            "input_table_name": self.input_table_name,
            "database_path": self.database_path,
            "aliased_columns": {
                column_name: aliases
                for column_name, aliases in alias_map.items()
                if column_name in self.input_fields
            },
        })
        return alias_map

    def _init_pages(self) -> None:
        self.addPage(self._build_details_page())
        self.addPage(self._build_model_page())
        self.addPage(self._build_mapping_page())
        self.addPage(self._build_map_topics_page())

    def _show_busy_dialog(self, *, title: str, label: str) -> None:
        """Show or refresh an indeterminate progress dialog for background work."""
        if self._progress_dialog is None:
            self._progress_dialog = QProgressDialog(label, None, 0, 0, self)
            self._progress_dialog.setWindowTitle(title)
            self._progress_dialog.setWindowModality(Qt.WindowModality.WindowModal)
            self._progress_dialog.setMinimumDuration(0)
            self._progress_dialog.setAutoClose(False)
            self._progress_dialog.setAutoReset(False)
            self._progress_dialog.show()
        else:
            self._progress_dialog.setWindowTitle(title)
            self._progress_dialog.setLabelText(label)
        QApplication.processEvents()

    def _close_busy_dialog(self) -> None:
        """Close any active indeterminate progress dialog."""
        if self._progress_dialog:
            self._progress_dialog.close()
            self._progress_dialog = None
            QApplication.processEvents()

    def _on_worker_progress(self, message: str) -> None:
        """Reflect background worker progress in the current busy dialog."""
        if self._progress_dialog:
            self._progress_dialog.setLabelText(message)
            QApplication.processEvents()

    def _build_details_page(self) -> QWizardPage:
        page = QWizardPage()
        page.setTitle(tr("create_netweaver.page_details_title"))
        page.setSubTitle(tr("create_netweaver.page_details_subtitle"))

        layout = QVBoxLayout(page)

        not_set = tr("create_netweaver.not_set")
        context_group = QGroupBox(tr("create_netweaver.context_group"))
        context_layout = QGridLayout(context_group)
        context_layout.addWidget(QLabel(tr("create_netweaver.lbl_workspace")), 0, 0)
        context_layout.addWidget(QLabel(self.project_id), 0, 1)
        context_layout.addWidget(QLabel(tr("create_netweaver.lbl_study")), 1, 0)
        context_layout.addWidget(QLabel(self.assessment_id), 1, 1)
        context_layout.addWidget(QLabel(tr("create_netweaver.lbl_input_layer")), 2, 0)
        context_layout.addWidget(QLabel(self.input_layer_name or not_set), 2, 1)
        context_layout.addWidget(QLabel(tr("create_netweaver.lbl_input_table")), 3, 0)
        context_layout.addWidget(QLabel(self.input_table_name or not_set), 3, 1)
        layout.addWidget(context_group)

        layout.addWidget(QLabel(tr("create_netweaver.lbl_analysis_name")))
        self.analysis_name_input = QLineEdit()
        layout.addWidget(self.analysis_name_input)

        layout.addWidget(QLabel(tr("create_netweaver.lbl_comments")))
        self.comments_input = RichCommentEditor(self)
        layout.addWidget(self.comments_input, 1)

        self.run_immediately_checkbox = QCheckBox(tr("create_netweaver.run_immediately"))
        self.run_immediately_checkbox.setChecked(True)
        layout.addWidget(self.run_immediately_checkbox)

        layout.addStretch()
        return page

    def _build_model_page(self) -> QWizardPage:
        page = QWizardPage()
        page.setTitle(tr("create_netweaver.page_model_title"))
        page.setSubTitle(tr("create_netweaver.page_model_subtitle"))

        layout = QVBoxLayout(page)

        model_row = QHBoxLayout()
        self.model_file_input = QLineEdit()
        self.model_file_input.setPlaceholderText(tr("create_netweaver.model_placeholder"))
        model_row.addWidget(self.model_file_input)

        browse_button = QPushButton(tr("create_netweaver.btn_browse"))
        browse_button.clicked.connect(self._browse_model_file)
        model_row.addWidget(browse_button)

        self.pick_registry_button = QPushButton(tr("create_netweaver.btn_pick_registry"))
        self.pick_registry_button.setToolTip(tr("create_netweaver.tip_pick_registry"))
        self.pick_registry_button.clicked.connect(self._pick_registry_model)
        model_row.addWidget(self.pick_registry_button)

        self.load_button = QPushButton(tr("create_netweaver.btn_reload_model"))
        self.load_button.clicked.connect(self._load_model_info)
        model_row.addWidget(self.load_button)
        self.load_button.hide()

        layout.addLayout(model_row)

        self.model_summary_label = QLabel(tr("create_netweaver.model_not_loaded"))
        self.model_summary_label.setWordWrap(True)
        layout.addWidget(self.model_summary_label)

        topics_layout = QHBoxLayout()

        available_group = QGroupBox(tr("create_netweaver.group_available_topics"))
        available_layout = QVBoxLayout(available_group)
        self.available_topics_tree = QTreeWidget()
        self.available_topics_tree.setHeaderHidden(True)
        self.available_topics_tree.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection)
        available_layout.addWidget(self.available_topics_tree)

        selected_group = QGroupBox(tr("create_netweaver.group_selected_topics"))
        selected_layout = QVBoxLayout(selected_group)
        self.selected_topics_tree = QTreeWidget()
        self.selected_topics_tree.setHeaderHidden(True)
        self.selected_topics_tree.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection)
        selected_layout.addWidget(self.selected_topics_tree)

        action_layout = QVBoxLayout()
        add_button = QPushButton(tr("create_netweaver.btn_add"))
        add_button.clicked.connect(self._add_selected_topics)
        action_layout.addWidget(add_button)

        remove_button = QPushButton(tr("create_netweaver.btn_remove"))
        remove_button.clicked.connect(self._remove_selected_topics)
        action_layout.addWidget(remove_button)

        add_all_button = QPushButton(tr("create_netweaver.btn_add_all"))
        add_all_button.clicked.connect(self._add_all_topics)
        action_layout.addWidget(add_all_button)
        action_layout.addStretch()

        topics_layout.addWidget(available_group, 1)
        topics_layout.addLayout(action_layout)
        topics_layout.addWidget(selected_group, 1)
        layout.addLayout(topics_layout)

        return page

    def _build_mapping_page(self) -> QWizardPage:
        page = QWizardPage()
        page.setTitle(tr("create_netweaver.page_mapping_title"))
        page.setSubTitle(tr("create_netweaver.page_mapping_subtitle"))

        layout = QVBoxLayout(page)

        self.mapping_summary_label = QLabel(
            tr("create_netweaver.mapping_default")
        )
        self.mapping_summary_label.setWordWrap(True)
        layout.addWidget(self.mapping_summary_label)

        action_row = QHBoxLayout()
        self.load_mapping_button = QPushButton(tr("create_netweaver.btn_load_mapping"))
        self.load_mapping_button.clicked.connect(self._load_saved_mapping_from_store)
        action_row.addWidget(self.load_mapping_button)

        self.save_mapping_button = QPushButton(tr("create_netweaver.btn_save_mapping"))
        self.save_mapping_button.clicked.connect(self._save_mapping_to_store)
        action_row.addWidget(self.save_mapping_button)
        action_row.addStretch()
        layout.addLayout(action_row)

        self.mapping_table = QTableWidget(0, 2)
        self.mapping_table.setHorizontalHeaderLabels([
            tr("create_netweaver.col_variable"),
            tr("create_netweaver.col_input_field"),
        ])
        self.mapping_table.horizontalHeader().setStretchLastSection(True)
        layout.addWidget(self.mapping_table)

        layout.addStretch()
        return page

    def _build_map_topics_page(self) -> QWizardPage:
        page = QWizardPage()
        page.setTitle(tr("create_netweaver.page_map_topics_title"))
        page.setSubTitle(tr("create_netweaver.page_map_topics_subtitle"))

        layout = QVBoxLayout(page)

        self.map_topics_summary_label = QLabel(
            tr("create_netweaver.map_topics_hint")
        )
        self.map_topics_summary_label.setWordWrap(True)
        layout.addWidget(self.map_topics_summary_label)

        self.map_topics_list = QListWidget()
        layout.addWidget(self.map_topics_list, 1)

        button_row = QHBoxLayout()
        check_all_button = QPushButton(tr("create_netweaver.btn_check_all"))
        check_all_button.clicked.connect(lambda: self._set_all_map_topics_checked(True))
        button_row.addWidget(check_all_button)

        uncheck_all_button = QPushButton(tr("create_netweaver.btn_uncheck_all"))
        uncheck_all_button.clicked.connect(lambda: self._set_all_map_topics_checked(False))
        button_row.addWidget(uncheck_all_button)
        button_row.addStretch()
        layout.addLayout(button_row)

        return page

    def _browse_model_file(self) -> None:
        model_file, _ = QFileDialog.getOpenFileName(
            self,
            tr("create_netweaver.file_dialog_select"),
            "",
            tr("create_netweaver.file_dialog_filter"),
        )
        if model_file:
            # A manually chosen file is not a registry version; drop any
            # previously-picked registry snapshot linkage.
            self._registry_model_snapshot_id = ""
            self.model_file_input.setText(model_file)
            self._load_model_info()

    def _pick_registry_model(self) -> None:
        """Pick a published model version from the central registry (item 6).

        On selection: import the version as an embedded+referenced
        model_snapshots row, materialize its bytes to a temp file, set that as
        the chosen model path, and remember the snapshot id so the created task
        is linked to the verified registry model.
        """
        from .model_registry_picker_dialog import ModelRegistryPickerDialog
        from .. import model_registry

        dialog = ModelRegistryPickerDialog(model_kind="netweaver", parent=self)
        version = dialog.get_selected_version()
        if version is None:
            return

        snapshot_id = ""
        if self.metadata_manager is not None:
            try:
                snapshot_id = model_registry.import_version_as_snapshot(
                    self.metadata_manager,
                    version,
                    registry_path=dialog.registry_path,
                ) or ""
            except Exception as exc:
                debug_logger.error("Registry snapshot import failed", {"exception": str(exc)})

        try:
            model_path = model_registry.materialize_version(version)
        except Exception as exc:
            QMessageBox.warning(
                self,
                tr("create_netweaver.err_model_required_title"),
                tr("create_netweaver.registry_materialize_failed", error=str(exc)),
            )
            return

        self._registry_model_snapshot_id = snapshot_id
        self.model_file_input.setText(model_path)
        self._load_model_info()

    @staticmethod
    def _normalize_model_path(raw_path: str) -> Path:
        cleaned = raw_path.strip().strip('"').strip("'")
        return Path(cleaned).expanduser()

    def _load_model_info(self) -> None:
        raw_model_path = self.model_file_input.text()
        model_path = self._normalize_model_path(raw_model_path)
        if not str(model_path).strip():
            QMessageBox.warning(
                self,
                tr("create_netweaver.err_model_required_title"),
                tr("create_netweaver.err_model_required_body"),
            )
            return

        if not self.engine_path or not Path(self.engine_path).exists():
            QMessageBox.critical(
                self,
                tr("create_netweaver.err_engine_missing_title"),
                tr("create_netweaver.err_engine_missing_body", path=self.engine_path),
            )
            return

        debug_logger.section("NetWeaver model load")
        debug_logger.debug("Loading model info", {
            "engine_path": self.engine_path,
            "raw_model_path": raw_model_path,
            "normalized_model_path": str(model_path),
            "absolute_model_path": str(model_path.absolute()),
            "model_exists": model_path.exists(),
            "model_suffix": model_path.suffix.lower(),
        })

        if not model_path.exists():
            QMessageBox.critical(
                self,
                tr("create_netweaver.err_load_failed_title"),
                tr("create_netweaver.err_load_failed_body", path=model_path),
            )
            return

        # Disable button and show progress
        self.load_button.setEnabled(False)
        self._show_busy_dialog(
            title=tr("create_netweaver.busy_loading_model_title"),
            label=tr("create_netweaver.busy_loading_model_label"),
        )

        # Create / reuse bridge
        self._bridge = NetWeaverBridge(self.engine_path)
        self._model_path_str = str(model_path)

        # Launch first worker: get_model_info
        worker = BridgeWorker(self._bridge.get_model_info, self._model_path_str)
        worker.signals.finished.connect(self._on_model_info_received)
        worker.signals.error.connect(self._on_model_load_error)
        worker.signals.progress.connect(self._on_worker_progress)
        self._active_workers.append(worker)
        worker.start()

    def _on_model_info_received(self, result) -> None:
        """Handle model_info result, then launch topic_tree worker."""
        # Remove finished worker
        self._active_workers = [w for w in self._active_workers if w.isRunning()]

        self.model_info = result
        debug_logger.debug("NetWeaver model info loaded", {
            "model_name": self.model_info.get("Name", ""),
            "goal_count": self.model_info.get("GoalCount", ""),
            "variable_count": self.model_info.get("VariableCount", ""),
        })

        # Update progress label for step 2
        if self._progress_dialog:
            self._progress_dialog.setLabelText(tr("create_netweaver.busy_loading_topics_label"))
            QApplication.processEvents()

        # Launch second worker: get_topic_tree
        worker = BridgeWorker(self._bridge.get_topic_tree, self._model_path_str)
        worker.signals.finished.connect(self._on_topic_tree_received)
        worker.signals.error.connect(self._on_model_load_error)
        worker.signals.progress.connect(self._on_worker_progress)
        self._active_workers.append(worker)
        worker.start()

    def _on_topic_tree_received(self, result) -> None:
        """Handle topic_tree result and update the UI."""
        self._active_workers = [w for w in self._active_workers if w.isRunning()]

        self.topic_tree = result
        debug_logger.debug("NetWeaver topic tree loaded", {
            "topic_tree_nodes": len(self.topic_tree),
        })
        self.available_variables = []
        self._variable_ignore_flags = {}

        self._populate_available_topic_tree()
        self.selected_topics_tree.clear()

        model_path = Path(self._model_path_str)
        self.model_summary_label.setText(
            tr(
                "create_netweaver.model_loaded_summary",
                name=self.model_info.get("Name", model_path.name),
                topics=self.available_topics_tree.topLevelItemCount(),
                goals=self.model_info.get("GoalCount", ""),
                variables=self.model_info.get("VariableCount", ""),
            )
        )
        self.mapping_summary_label.setText(tr("create_netweaver.model_loaded_help"))
        self._load_saved_mapping_from_store(show_message=False)

        # Dismiss progress and re-enable
        self._close_busy_dialog()
        self.load_button.setEnabled(True)

    def _on_model_load_error(self, error_msg: str) -> None:
        """Handle errors from either model-load worker."""
        self._active_workers = [w for w in self._active_workers if w.isRunning()]

        debug_logger.error("Failed to load NetWeaver model", {"exception": error_msg})

        self._close_busy_dialog()
        self.load_button.setEnabled(True)

        QMessageBox.critical(self, tr("create_netweaver.err_load_failed_title"), error_msg)

    def _on_page_changed(self, page_id: int) -> None:
        """Refresh the final map-topics page when the user navigates to it."""
        if page_id == 3:
            self._populate_map_topics_page()

    def _add_selected_topics(self) -> None:
        selected_items = self._filter_top_level_selected_items(self.available_topics_tree.selectedItems())
        for item in selected_items:
            node_id = self._topic_item_id(item)
            if self._tree_contains_topic_id(self.selected_topics_tree, node_id):
                continue
            self.selected_topics_tree.addTopLevelItem(self._clone_topic_item(item))
        self.selected_topics_tree.expandAll()
        self._refresh_mapping_table()

    def _remove_selected_topics(self) -> None:
        selected_items = self._filter_top_level_selected_items(self.selected_topics_tree.selectedItems())
        for item in selected_items:
            parent = item.parent()
            if parent is None:
                index = self.selected_topics_tree.indexOfTopLevelItem(item)
                if index >= 0:
                    self.selected_topics_tree.takeTopLevelItem(index)
            else:
                parent.removeChild(item)
        self._refresh_mapping_table()

    def _add_all_topics(self) -> None:
        for index in range(self.available_topics_tree.topLevelItemCount()):
            item = self.available_topics_tree.topLevelItem(index)
            if item is None:
                continue
            if self._tree_contains_topic_id(self.selected_topics_tree, self._topic_item_id(item)):
                continue
            self.selected_topics_tree.addTopLevelItem(self._clone_topic_item(item))
        self.selected_topics_tree.expandAll()
        self._refresh_mapping_table()

    def _populate_available_topic_tree(self) -> None:
        """Build the available-topics tree from the bridge topic hierarchy payload."""
        self.available_topics_tree.clear()

        items_by_id: Dict[int, QTreeWidgetItem] = {}
        pending_children: Dict[int, List[QTreeWidgetItem]] = {}

        for node in self.topic_tree:
            name = str(node.get("Name", "")).strip()
            if not name:
                continue

            node_id = int(node.get("ID", 0) or 0)
            parent_id = int(node.get("ParentID", 0) or 0)
            item = self._create_topic_item(name=name, node_id=node_id, parent_id=parent_id)
            items_by_id[node_id] = item

            parent_item = items_by_id.get(parent_id)
            if parent_id and parent_item is not None:
                parent_item.addChild(item)
            else:
                self.available_topics_tree.addTopLevelItem(item)
                pending_children.setdefault(parent_id, [])

            for waiting_child in pending_children.pop(node_id, []):
                item.addChild(waiting_child)

            if parent_id and parent_item is None:
                pending_children.setdefault(parent_id, []).append(item)
                index = self.available_topics_tree.indexOfTopLevelItem(item)
                if index >= 0:
                    self.available_topics_tree.takeTopLevelItem(index)

        self.available_topics_tree.expandAll()
        self._select_initial_available_topic()

    def _select_initial_available_topic(self) -> None:
        """Preselect the first available topic branch so Add works immediately."""
        first_item = self.available_topics_tree.topLevelItem(0)
        if first_item is None:
            return
        self.available_topics_tree.clearSelection()
        first_item.setSelected(True)
        self.available_topics_tree.setCurrentItem(first_item)
        self.available_topics_tree.scrollToItem(first_item)

    @staticmethod
    def _create_topic_item(*, name: str, node_id: int, parent_id: int) -> QTreeWidgetItem:
        item = QTreeWidgetItem([name])
        item.setData(0, Qt.ItemDataRole.UserRole, {"id": node_id, "parent_id": parent_id, "name": name})
        return item

    @staticmethod
    def _clone_topic_item(item: QTreeWidgetItem) -> QTreeWidgetItem:
        data = item.data(0, Qt.ItemDataRole.UserRole) or {}
        clone = QTreeWidgetItem([item.text(0)])
        clone.setData(0, Qt.ItemDataRole.UserRole, dict(data))
        for index in range(item.childCount()):
            clone.addChild(CreateNetWeaverAnalysisDialog._clone_topic_item(item.child(index)))
        return clone

    @staticmethod
    def _topic_item_id(item: Optional[QTreeWidgetItem]) -> int:
        if item is None:
            return 0
        data = item.data(0, Qt.ItemDataRole.UserRole) or {}
        return int(data.get("id", 0) or 0)

    @staticmethod
    def _filter_top_level_selected_items(items: List[QTreeWidgetItem]) -> List[QTreeWidgetItem]:
        """Remove child selections when an ancestor is also selected."""
        selected_set = {id(item) for item in items}
        filtered: List[QTreeWidgetItem] = []
        for item in items:
            ancestor = item.parent()
            skip = False
            while ancestor is not None:
                if id(ancestor) in selected_set:
                    skip = True
                    break
                ancestor = ancestor.parent()
            if not skip:
                filtered.append(item)
        return filtered

    def _tree_contains_topic_id(self, tree_widget: QTreeWidget, node_id: int) -> bool:
        if not node_id:
            return False

        def _walk(item: QTreeWidgetItem) -> bool:
            if self._topic_item_id(item) == node_id:
                return True
            for index in range(item.childCount()):
                if _walk(item.child(index)):
                    return True
            return False

        for index in range(tree_widget.topLevelItemCount()):
            item = tree_widget.topLevelItem(index)
            if item is not None and _walk(item):
                return True
        return False

    def _refresh_mapping_table(self) -> None:
        selected_topics = self.get_selected_topics()
        if not selected_topics:
            self.mapping_table.setRowCount(0)
            if hasattr(self, "map_topics_list"):
                self.map_topics_list.clear()
            return

        raw_model_path = self.model_file_input.text()
        model_path = self._normalize_model_path(raw_model_path)
        debug_logger.debug("Refreshing topic variable mappings", {
            "raw_model_path": raw_model_path,
            "normalized_model_path": str(model_path),
            "model_exists": model_path.exists(),
            "selected_topics": selected_topics,
        })

        # Show progress dialog
        self._show_busy_dialog(
            title=tr("create_netweaver.busy_loading_variables_title"),
            label=tr("create_netweaver.busy_loading_variables_label"),
        )

        # Reuse existing bridge or create one
        if not self._bridge:
            self._bridge = NetWeaverBridge(self.engine_path)

        worker = BridgeWorker(
            self._bridge.get_topic_variables,
            str(model_path),
            selected_topics,
        )
        worker.signals.finished.connect(self._on_topic_variables_received)
        worker.signals.error.connect(self._on_topic_variables_error)
        worker.signals.progress.connect(self._on_worker_progress)
        self._active_workers.append(worker)
        worker.start()

    def _on_topic_variables_received(self, result) -> None:
        """Handle topic variables result and populate the mapping table."""
        self._active_workers = [w for w in self._active_workers if w.isRunning()]

        self._close_busy_dialog()

        self.available_variables = result.get("Variables", [])
        expanded_topics = result.get("ExpandedTopics", [])
        # D2: the engine now returns ignore-flagged variables too, tagged, instead
        # of dropping them. They stay mappable and auto-mappable — a
        # ConditionalIgnore variable only drops out of evaluation while it has NO
        # VALUE, so mapping a column is precisely what revives it (this is what
        # brought TCAV41's ozone subtree back). PersistentIgnore is unconditional,
        # so those are surfaced honestly but labelled as inert.
        self._variable_ignore_flags = {
            str(detail.get("name", "")).strip(): {
                "persistent_ignore": bool(detail.get("persistent_ignore")),
                "conditional_ignore": bool(detail.get("conditional_ignore")),
            }
            for detail in result.get("VariableDetails", []) or []
            if str(detail.get("name", "")).strip()
        }

        if not self.available_variables:
            self.mapping_table.setRowCount(0)
            self.mapping_summary_label.setText(tr("create_netweaver.no_variables_resolved"))
            return

        self.mapping_table.setRowCount(len(self.available_variables))
        self._column_aliases = self._build_column_aliases()
        suggested_pairs = {
            target: source
            for source, target in ModelVariableExtractor.suggest_mappings(
                self.input_fields,
                self.available_variables,
                self._column_aliases,
            )
        }

        ignored_count = 0
        for row, variable in enumerate(self.available_variables):
            flags = self._variable_ignore_flags.get(variable, {})
            if flags.get("persistent_ignore"):
                note = tr("create_netweaver.var_persistent_ignore_note")
            elif flags.get("conditional_ignore"):
                note = tr("create_netweaver.var_conditional_ignore_note")
            else:
                note = ""

            variable_item = QTableWidgetItem(f"{variable}  ({note})" if note else variable)
            # The visible label may carry an annotation, so the raw variable name
            # lives in UserRole — _collect_field_mappings and
            # _apply_saved_mapping_payload read it from there and stay exact.
            variable_item.setData(Qt.ItemDataRole.UserRole, variable)
            if note:
                ignored_count += 1
                variable_item.setToolTip(note)
            variable_item.setFlags(variable_item.flags() & ~Qt.ItemFlag.ItemIsEditable)
            self.mapping_table.setItem(row, 0, variable_item)

            combo = QComboBox()
            combo.addItem("")
            combo.addItems(self.input_fields)
            suggested_source = suggested_pairs.get(variable, "")
            if suggested_source:
                combo.setCurrentText(suggested_source)
            self.mapping_table.setCellWidget(row, 1, combo)

        self.mapping_table.resizeColumnsToContents()
        summary = tr(
            "create_netweaver.prepared_mappings_help",
            count=len(self.available_variables),
            topics=len(expanded_topics),
        )
        if ignored_count:
            summary += " " + tr(
                "create_netweaver.mapping_ignored_note", count=ignored_count
            )
        self.mapping_summary_label.setText(summary)
        self._populate_map_topics_page(expanded_topics)
        if self._pending_mapping_payload:
            self._apply_saved_mapping_payload(self._pending_mapping_payload)
            self._pending_mapping_payload = None

    def _on_topic_variables_error(self, error_msg: str) -> None:
        """Handle errors from the topic variables worker."""
        self._active_workers = [w for w in self._active_workers if w.isRunning()]

        self._close_busy_dialog()

        debug_logger.error("Failed to retrieve topic variables", {"exception": error_msg})
        self.available_variables = []
        self._variable_ignore_flags = {}
        self.mapping_table.setRowCount(0)
        self.mapping_summary_label.setText(tr("create_netweaver.err_topic_variables_failed"))
        if hasattr(self, "map_topics_list"):
            self.map_topics_list.clear()

    def get_selected_topics(self) -> List[str]:
        def _walk(item: QTreeWidgetItem, topics: List[str], seen: set[str]) -> None:
            topic_name = item.text(0).strip()
            if topic_name and topic_name.lower() not in seen:
                topics.append(topic_name)
                seen.add(topic_name.lower())
            for child_index in range(item.childCount()):
                _walk(item.child(child_index), topics, seen)

        topics: List[str] = []
        seen: set[str] = set()
        for index in range(self.selected_topics_tree.topLevelItemCount()):
            item = self.selected_topics_tree.topLevelItem(index)
            if item is not None:
                _walk(item, topics, seen)
        return topics

    def _populate_map_topics_page(self, expanded_topics: Optional[List[str]] = None) -> None:
        """Populate the checked list of topics/goals to add as map layers."""
        if not hasattr(self, "map_topics_list"):
            return

        previous_state: Dict[str, bool] = {}
        for index in range(self.map_topics_list.count()):
            item = self.map_topics_list.item(index)
            if item is None:
                continue
            previous_state[item.text().strip().lower()] = item.checkState() == Qt.CheckState.Checked

        topics = expanded_topics if expanded_topics is not None else self.get_selected_topics()
        ordered_topics: List[str] = []
        seen: set[str] = set()
        for topic in topics:
            topic_text = str(topic).strip()
            if topic_text and topic_text.lower() not in seen:
                ordered_topics.append(topic_text)
                seen.add(topic_text.lower())

        self.map_topics_list.clear()
        for topic in ordered_topics:
            item = QListWidgetItem(topic)
            item.setFlags(item.flags() | Qt.ItemFlag.ItemIsUserCheckable | Qt.ItemFlag.ItemIsEnabled)
            checked = previous_state.get(topic.lower(), True)
            item.setCheckState(Qt.CheckState.Checked if checked else Qt.CheckState.Unchecked)
            self.map_topics_list.addItem(item)

        if ordered_topics:
            self.map_topics_summary_label.setText(
                tr("create_netweaver.map_topics_count_header", count=len(ordered_topics))
            )
        else:
            self.map_topics_summary_label.setText(tr("create_netweaver.map_topics_empty_header"))

    def _set_all_map_topics_checked(self, checked: bool) -> None:
        """Check or uncheck all map-topic entries."""
        if not hasattr(self, "map_topics_list"):
            return
        state = Qt.CheckState.Checked if checked else Qt.CheckState.Unchecked
        for index in range(self.map_topics_list.count()):
            item = self.map_topics_list.item(index)
            if item is not None:
                item.setCheckState(state)

    def get_map_topics(self) -> List[str]:
        """Return the subset of expanded topics/goals that should become map layers."""
        if not hasattr(self, "map_topics_list"):
            return self.get_selected_topics()

        map_topics: List[str] = []
        for index in range(self.map_topics_list.count()):
            item = self.map_topics_list.item(index)
            if item is not None and item.checkState() == Qt.CheckState.Checked:
                topic_text = item.text().strip()
                if topic_text:
                    map_topics.append(topic_text)
        return map_topics

    def _collect_field_mappings(self) -> List[Dict[str, Any]]:
        mappings: List[Dict[str, Any]] = []
        for row in range(self.mapping_table.rowCount()):
            variable_item = self.mapping_table.item(row, 0)
            field_combo = self.mapping_table.cellWidget(row, 1)

            if not variable_item or field_combo is None:
                continue

            # D2: label may carry an ignore annotation; UserRole holds the raw name.
            variable_name = variable_item.data(Qt.ItemDataRole.UserRole) or variable_item.text()
            source_field = field_combo.currentText().strip()

            if source_field:
                mappings.append({
                    "target_variable": variable_name,
                    "source_column": source_field,
                })
        return mappings

    def _selected_topic_branch_names(self) -> List[str]:
        branches: List[str] = []
        seen: set[str] = set()
        for index in range(self.selected_topics_tree.topLevelItemCount()):
            item = self.selected_topics_tree.topLevelItem(index)
            if item is None:
                continue
            name = item.text(0).strip()
            if name and name.lower() not in seen:
                branches.append(name)
                seen.add(name.lower())
        return branches

    def _mapping_model_identifier(self) -> str:
        model_path = self._normalize_model_path(self.model_file_input.text())
        try:
            return str(model_path.resolve())
        except Exception:
            return str(model_path)

    def _current_mapping_payload(self) -> Dict[str, Any]:
        return {
            "analysis_name": self.analysis_name_input.text().strip(),
            "comments": self.comments_input.plain_text(),
            "comments_html": self.comments_input.html(),
            "selected_topic_branches": self._selected_topic_branch_names(),
            "selected_topics": self.get_selected_topics(),
            "field_mappings": self._collect_field_mappings(),
            "map_topics": self.get_map_topics(),
        }

    def _find_available_topic_item(self, topic_name: str) -> Optional[QTreeWidgetItem]:
        normalized_name = str(topic_name).strip().lower()
        if not normalized_name:
            return None
        for index in range(self.available_topics_tree.topLevelItemCount()):
            item = self.available_topics_tree.topLevelItem(index)
            if item is not None and item.text(0).strip().lower() == normalized_name:
                return item
        return None

    def _apply_saved_mapping_payload(self, payload: Dict[str, Any]) -> None:
        if not payload:
            return

        field_mapping_lookup = {}
        for mapping in payload.get("field_mappings", []) or []:
            target_variable = str(mapping.get("target_variable", "")).strip()
            if target_variable:
                field_mapping_lookup[target_variable] = mapping

        for row in range(self.mapping_table.rowCount()):
            variable_item = self.mapping_table.item(row, 0)
            field_combo = self.mapping_table.cellWidget(row, 1)
            if variable_item is None or field_combo is None:
                continue
            variable_name = variable_item.data(Qt.ItemDataRole.UserRole) or variable_item.text()
            saved_mapping = field_mapping_lookup.get(str(variable_name).strip())
            if not saved_mapping:
                continue
            field_combo.setCurrentText(str(saved_mapping.get("source_column", "")).strip())

        saved_map_topics = {
            str(topic).strip().lower()
            for topic in payload.get("map_topics", []) or []
            if str(topic).strip()
        }
        if saved_map_topics and hasattr(self, "map_topics_list"):
            for index in range(self.map_topics_list.count()):
                item = self.map_topics_list.item(index)
                if item is None:
                    continue
                item.setCheckState(
                    Qt.CheckState.Checked if item.text().strip().lower() in saved_map_topics else Qt.CheckState.Unchecked
                )

    def _load_saved_mapping_from_store(self, *, show_message: bool = True) -> None:
        if self.metadata_manager is None:
            if show_message:
                QMessageBox.information(
                    self,
                    tr("create_netweaver.saved_title"),
                    tr("create_netweaver.saved_no_metadata"),
                )
            return

        model_identifier = self._mapping_model_identifier()
        preset = self.metadata_manager.get_field_mapping_preset(
            project_id=self.project_id,
            engine_type=EngineType.NETWEAVER.value,
            model_identifier=model_identifier,
            input_table_name=self.input_table_name,
        )
        payload = preset.get("payload", {}) if preset else {}
        if not payload:
            if show_message:
                QMessageBox.information(
                    self,
                    tr("create_netweaver.saved_title"),
                    tr("create_netweaver.saved_none_found"),
                )
            return

        selected_topic_branches = payload.get("selected_topic_branches", []) or []
        if selected_topic_branches and self.available_topics_tree.topLevelItemCount() > 0:
            self.selected_topics_tree.clear()
            for branch_name in selected_topic_branches:
                source_item = self._find_available_topic_item(branch_name)
                if source_item is not None:
                    self.selected_topics_tree.addTopLevelItem(self._clone_topic_item(source_item))
            self.selected_topics_tree.expandAll()
            self._pending_mapping_payload = payload
            self._refresh_mapping_table()
        else:
            self._pending_mapping_payload = payload
            self._apply_saved_mapping_payload(payload)

        if show_message:
            QMessageBox.information(
                self,
                tr("create_netweaver.saved_title"),
                tr("create_netweaver.saved_loaded"),
            )

    def _save_mapping_to_store(self) -> None:
        title = tr("create_netweaver.saved_title")
        if self.metadata_manager is None:
            QMessageBox.warning(self, title, tr("create_netweaver.saved_no_metadata_save"))
            return

        payload = self._current_mapping_payload()
        if not payload["selected_topic_branches"]:
            QMessageBox.warning(self, title, tr("create_netweaver.saved_select_topics_first"))
            return

        if not self.metadata_manager.save_field_mapping_preset(
            project_id=self.project_id,
            engine_type=EngineType.NETWEAVER.value,
            model_identifier=self._mapping_model_identifier(),
            input_table_name=self.input_table_name,
            payload=payload,
        ):
            QMessageBox.warning(self, title, tr("create_netweaver.saved_could_not_save"))
            return

        QMessageBox.information(self, title, tr("create_netweaver.saved_saved"))

    def validateCurrentPage(self) -> bool:
        current_id = self.currentId()
        title = tr("create_netweaver.validation_title")
        if current_id == 0:
            if not self.analysis_name_input.text().strip():
                QMessageBox.warning(self, title, tr("create_netweaver.validation_name_required"))
                return False
        elif current_id == 1:
            if not self.model_file_input.text().strip():
                QMessageBox.warning(self, title, tr("create_netweaver.validation_model_required"))
                return False
            if not self.get_selected_topics():
                QMessageBox.warning(self, title, tr("create_netweaver.validation_topic_required"))
                return False
        elif current_id == 2:
            mappings = self._collect_field_mappings()
            if not mappings:
                QMessageBox.warning(self, title, tr("create_netweaver.validation_mapping_required"))
                return False
        return super().validateCurrentPage()

    def accept(self) -> None:
        selected_topics = self.get_selected_topics()
        field_mappings = self._collect_field_mappings()
        map_topics = self.get_map_topics() or selected_topics

        parameters = {
            "project_id": self.project_id,
            "assessment_id": self.assessment_id,
            "input_layer_name": self.input_layer_name,
            "input_table_name": self.input_table_name,
            "model_file": self.model_file_input.text().strip(),
            "analysis_name": self.analysis_name_input.text().strip(),
            "comments": self.comments_input.plain_text(),
            "comments_html": self.comments_input.html(),
            "selected_topics": selected_topics,
            "selected_topic_branches": self._selected_topic_branch_names(),
            "field_mappings": field_mappings,
            "map_topics": map_topics,
            "run_immediately": self.run_immediately_checkbox.isChecked(),
        }

        if self.metadata_manager is not None:
            self.metadata_manager.save_field_mapping_preset(
                project_id=self.project_id,
                engine_type=EngineType.NETWEAVER.value,
                model_identifier=self._mapping_model_identifier(),
                input_table_name=self.input_table_name,
                payload=self._current_mapping_payload(),
            )

        if self._registry_model_snapshot_id:
            parameters["model_snapshot_id"] = self._registry_model_snapshot_id

        self._result_step = WorkflowStep(
            id=str(uuid.uuid4()),
            name=self.analysis_name_input.text().strip(),
            engine_type=EngineType.NETWEAVER,
            parameters=parameters,
        )
        if self._registry_model_snapshot_id:
            self._result_step.metadata["model_snapshot_id"] = self._registry_model_snapshot_id

        debug_logger.section("NetWeaver wizard accepted")
        debug_logger.debug("Wizard payload", {
            "project_id": self.project_id,
            "assessment_id": self.assessment_id,
            "input_layer_name": self.input_layer_name,
            "input_table_name": self.input_table_name,
            "selected_topics": len(selected_topics),
            "map_topics": len(map_topics),
            "available_variables": len(self.available_variables),
            "field_mappings": len(field_mappings),
            "run_immediately": parameters["run_immediately"],
        })

        super().accept()

    def get_workflow_step(self) -> Optional[WorkflowStep]:
        if exec_dialog(self) == QDialog.DialogCode.Accepted:
            return self._result_step
        return None

    def prime_from_step(self, step) -> None:
        """Hydrate the wizard fields from an existing WorkflowDefinitionStep.

        Mirrors the C# PrimeNetWeaverConfigureOnlyAsync pattern from
        WizardLauncher.cs: construct the dialog normally, then call this
        before show. Lets users re-open the wizard on an existing task
        without losing what they had configured.

        Restores: analysis name, model file path, comments, run-immediately
        flag. Topic selections and field mappings depend on the model being
        loaded; the user can click "Load Model" to repopulate those.
        """
        if step is None:
            return
        try:
            config = getattr(step, "config", None) or {}
            params = config.get("task_parameters") or {}

            name = (
                str(params.get("analysis_name") or step.name or "")
            ).strip()
            if name:
                self.analysis_name_input.setText(name)

            model_file = str(params.get("model_file") or config.get("model_reference") or "").strip()
            if model_file:
                self.model_file_input.setText(model_file)

            comments = str(params.get("comments") or "").strip()
            comments_html = str(params.get("comments_html") or "")
            if comments or comments_html:
                self.comments_input.set_content(comments, comments_html)

            run_immediately = params.get("run_immediately")
            if run_immediately is not None:
                self.run_immediately_checkbox.setChecked(bool(run_immediately))
        except Exception:
            # Priming is best-effort — never block the user from opening the wizard.
            pass
