# SPDX-License-Identifier: LGPL-2.1-or-later
#
# Copyright (c) 2025 The FreeCAD Project

import FreeCAD
import os
import json

if FreeCAD.GuiUp:
    from PySide import QtCore, QtWidgets, QtGui
    from PySide.QtCore import QT_TRANSLATE_NOOP
    import FreeCADGui
    from draftutils.translate import translate

    # Create an alias for the Slot decorator for use within the GUI-only classes.
    Slot = QtCore.Slot
else:

    def translate(ctxt, txt):
        return txt

    def QT_TRANSLATE_NOOP(ctxt, txt):
        return txt

    # In headless mode, create a dummy decorator named 'Slot'. This allows the
    # Python interpreter to parse the @Slot syntax in GUI-only classes without
    # raising a NameError because QtCore is not imported.
    def Slot(*args, **kwargs):
        def decorator(func):
            return func

        return decorator


import ArchSql
from ArchSql import ReportStatement

if FreeCAD.GuiUp:
    ICON_STATUS_OK = FreeCADGui.getIcon(":/icons/edit_OK.svg")
    ICON_STATUS_WARN = FreeCADGui.getIcon(":/icons/Warning.svg")
    ICON_STATUS_ERROR = FreeCADGui.getIcon(":/icons/delete.svg")
    ICON_STATUS_INCOMPLETE = FreeCADGui.getIcon(":/icons/button_invalid.svg")
    ICON_EDIT = FreeCADGui.getIcon(":/icons/edit-edit.svg")
    ICON_ADD = FreeCADGui.getIcon(":/icons/list-add.svg")
    ICON_REMOVE = FreeCADGui.getIcon(":/icons/list-remove.svg")
    ICON_DUPLICATE = FreeCADGui.getIcon(":/icons/edit-copy.svg")


def _get_preset_paths(preset_type):
    """
    Gets the file paths for bundled (system) and user preset directories.

    Parameters
    ----------
    preset_type : str
        The type of preset, either 'query' or 'report'.

    Returns
    -------
    tuple
        A tuple containing (system_preset_dir, user_preset_dir).
    """
    if preset_type == "query":
        subdir = "QueryPresets"
    elif preset_type == "report":
        subdir = "ReportPresets"
    else:
        return None, None

    # Path to the bundled presets installed with FreeCAD
    system_path = os.path.join(
        FreeCAD.getResourceDir(), "Mod", "BIM", "Presets", "ArchReport", subdir
    )
    # Path to the user's custom presets in their AppData directory
    user_path = os.path.join(FreeCAD.getUserAppDataDir(), "BIM", "Presets", "ArchReport", subdir)

    return system_path, user_path


def _get_presets(preset_type):
    """
    Loads all bundled and user presets from the filesystem.

    This function scans the mirrored system and user directories, loading each
    valid .json file. It is resilient to errors in user-created files.

    Parameters
    ----------
    preset_type : str
        The type of preset to load, either 'query' or 'report'.

    Returns
    -------
    dict
        A dictionary mapping the preset's filename (its stable ID) to a
        dictionary containing its display name, data, and origin.
        Example:
        {
          "room-schedule.json": {"name": "Room Schedule", "data": {...}, "is_user": False},
          "c2f5b1a0...json": {"name": "My Custom Report", "data": {...}, "is_user": True}
        }
    """
    system_dir, user_dir = _get_preset_paths(preset_type)
    presets = {}

    def scan_directory(directory, is_user_preset):
        if not os.path.isdir(directory):
            return

        for filename in os.listdir(directory):
            if not filename.endswith(".json"):
                continue

            file_path = os.path.join(directory, filename)
            try:
                with open(file_path, "r", encoding="utf8") as f:
                    data = json.load(f)

                if "name" not in data:
                    # Graceful handling: use filename as fallback, log a warning
                    display_name = os.path.splitext(filename)[0]
                    FreeCAD.Console.PrintWarning(
                        f"BIM Report: Preset file '{file_path}' is missing a 'name' key. Using filename as fallback.\n"
                    )
                else:
                    display_name = data["name"]

                # Apply translation only to bundled system presets
                if not is_user_preset:
                    display_name = translate("Arch", display_name)

                presets[filename] = {"name": display_name, "data": data, "is_user": is_user_preset}

            except json.JSONDecodeError:
                # Graceful handling: skip malformed file, log a detailed error
                FreeCAD.Console.PrintError(
                    f"BIM Report: Could not parse preset file at '{file_path}'. It may contain a syntax error.\n"
                )
            except Exception as e:
                FreeCAD.Console.PrintError(
                    f"BIM Report: An unexpected error occurred while loading preset '{file_path}': {e}\n"
                )

    # Scan system presets first, then user presets. User presets will not
    # overwrite system presets as their filenames (UUIDs) are unique.
    scan_directory(system_dir, is_user_preset=False)
    scan_directory(user_dir, is_user_preset=True)

    return presets


def _save_preset(preset_type, name, data):
    """
    Saves a preset to a new, individual .json file with a UUID-based filename.

    This function handles name collision checks and ensures the user's preset
    is saved in their personal AppData directory.

    Parameters
    ----------
    preset_type : str
        The type of preset, either 'query' or 'report'.
    name : str
        The desired human-readable display name for the preset.
    data : dict
        The dictionary of preset data to be saved as JSON.
    """
    import uuid

    _, user_path = _get_preset_paths(preset_type)
    if not user_path:
        return

    os.makedirs(user_path, exist_ok=True)

    # --- Name Collision Handling ---
    existing_presets = _get_presets(preset_type)
    existing_display_names = {p["name"] for p in existing_presets.values() if p["is_user"]}

    final_name = name
    counter = 1
    while final_name in existing_display_names:
        final_name = f"{name} ({counter:03d})"
        counter += 1

    # The display name is stored inside the JSON content
    data_to_save = data.copy()
    data_to_save["name"] = final_name

    # The filename is a stable, unique identifier
    filename = f"{uuid.uuid4()}.json"
    file_path = os.path.join(user_path, filename)

    try:
        with open(file_path, "w", encoding="utf8") as f:
            json.dump(data_to_save, f, indent=2)
        FreeCAD.Console.PrintMessage(
            f"BIM Report: Preset '{final_name}' saved successfully to '{file_path}'.\n"
        )
    except Exception as e:
        FreeCAD.Console.PrintError(f"BIM Report: Could not save preset to '{file_path}': {e}\n")


def _rename_preset(preset_type, filename, new_name):
    """Renames a user preset by updating the 'name' key in its JSON file."""
    _, user_path = _get_preset_paths(preset_type)
    file_path = os.path.join(user_path, filename)

    if not os.path.exists(file_path):
        FreeCAD.Console.PrintError(
            f"BIM Report: Cannot rename preset. File not found: {file_path}\n"
        )
        return

    try:
        with open(file_path, "r", encoding="utf8") as f:
            data = json.load(f)

        data["name"] = new_name

        with open(file_path, "w", encoding="utf8") as f:
            json.dump(data, f, indent=2)
    except Exception as e:
        FreeCAD.Console.PrintError(f"BIM Report: Failed to rename preset file '{file_path}': {e}\n")


def _delete_preset(preset_type, filename):
    """Deletes a user preset file from disk."""
    _, user_path = _get_preset_paths(preset_type)
    file_path = os.path.join(user_path, filename)

    if not os.path.exists(file_path):
        FreeCAD.Console.PrintError(
            f"BIM Report: Cannot delete preset. File not found: {file_path}\n"
        )
        return

    try:
        os.remove(file_path)
    except Exception as e:
        FreeCAD.Console.PrintError(f"BIM Report: Failed to delete preset file '{file_path}': {e}\n")


if FreeCAD.GuiUp:

    class SqlQueryEditor(QtWidgets.QPlainTextEdit):
        """
        A custom QPlainTextEdit that provides autocompletion features.

        This class integrates QCompleter and handles key events to provide
        content-based sizing for the popup and a better user experience,
        such as accepting completions with the Tab key.
        """

        def __init__(self, parent=None):
            super().__init__(parent)
            self._completer = None
            self.setMouseTracking(True)  # Required to receive mouseMoveEvents
            self.api_docs = {}
            self.clauses = set()
            self.functions = {}

        def set_api_documentation(self, api_docs: dict):
            """Receives the API documentation from the panel and caches it."""
            self.api_docs = api_docs
            self.clauses = set(api_docs.get("clauses", []))
            # Create a flat lookup dictionary for fast access
            for category, func_list in api_docs.get("functions", {}).items():
                for func_data in func_list:
                    self.functions[func_data["name"]] = {
                        "category": category,
                        "signature": func_data["signature"],
                        "description": func_data["description"],
                    }

        def mouseMoveEvent(self, event: QtGui.QMouseEvent):
            """Overrides the mouse move event to show tooltips."""
            cursor = self.cursorForPosition(event.pos())
            cursor.select(QtGui.QTextCursor.WordUnderCursor)
            word = cursor.selectedText().upper()

            tooltip_text = self._get_tooltip_for_word(word)

            if tooltip_text:
                QtWidgets.QToolTip.showText(event.globalPos(), tooltip_text, self)
            else:
                QtWidgets.QToolTip.hideText()

            super().mouseMoveEvent(event)

        def _get_tooltip_for_word(self, word: str) -> str:
            """Builds the HTML-formatted tooltip string for a given word."""
            if not word:
                return ""

            # Check if the word is a function
            if word in self.functions:
                func_data = self.functions[word]
                # Format a rich HTML tooltip for functions
                return (
                    f"<p style='white-space:nowrap'><code><b>{func_data['signature']}</b></code><br>"
                    f"<i>{func_data['category']}</i><br>"
                    f"{func_data['description']}</p>"
                )

            # Check if the word is a clause
            if word in self.clauses:
                # Format a simple, translatable tooltip for clauses
                # The string itself is marked for translation here.
                return f"<i>{translate('Arch', 'SQL Clause')}</i>"

            return ""

        def setCompleter(self, completer):
            if self._completer:
                self._completer.activated.disconnect(self.insertCompletion)

            self._completer = completer
            if not self._completer:
                return

            self._completer.setWidget(self)
            self._completer.setCompletionMode(QtWidgets.QCompleter.PopupCompletion)
            self._completer.activated.connect(self.insertCompletion)

        def completer(self):
            return self._completer

        def insertCompletion(self, completion):
            if self._completer.widget() is not self:
                return

            tc = self.textCursor()
            tc.select(QtGui.QTextCursor.WordUnderCursor)
            tc.insertText(completion)
            self.setTextCursor(tc)

        def textUnderCursor(self):
            tc = self.textCursor()
            tc.select(QtGui.QTextCursor.WordUnderCursor)
            return tc.selectedText()

        def keyPressEvent(self, event):
            # Pass key events to the completer first if its popup is visible.
            if self._completer and self._completer.popup().isVisible():
                if event.key() in (
                    QtCore.Qt.Key_Enter,
                    QtCore.Qt.Key_Return,
                    QtCore.Qt.Key_Escape,
                    QtCore.Qt.Key_Tab,
                    QtCore.Qt.Key_Backtab,
                ):
                    event.ignore()
                    return

            # Let the parent handle the key press to ensure normal typing works.
            super().keyPressEvent(event)

            # --- Autocompletion Trigger Logic ---

            # A Ctrl+Space shortcut can also be used to trigger completion.
            is_shortcut = (
                event.modifiers() & QtCore.Qt.ControlModifier and event.key() == QtCore.Qt.Key_Space
            )

            completion_prefix = self.textUnderCursor()

            # Don't show completer for very short prefixes unless forced by shortcut.
            if not is_shortcut and len(completion_prefix) < 2:
                self._completer.popup().hide()
                return

            # Show the completer if the prefix has changed.
            if completion_prefix != self._completer.completionPrefix():
                self._completer.setCompletionPrefix(completion_prefix)
                # Select the first item by default for a better UX.
                self._completer.popup().setCurrentIndex(
                    self._completer.completionModel().index(0, 0)
                )

            # --- Sizing and Positioning Logic (The critical fix) ---
            cursor_rect = self.cursorRect()

            # Calculate the required width based on the content of the popup.
            popup_width = (
                self._completer.popup().sizeHintForColumn(0)
                + self._completer.popup().verticalScrollBar().sizeHint().width()
            )
            cursor_rect.setWidth(popup_width)

            # Show the completer.
            self._completer.complete(cursor_rect)


class _ArchReportDocObserver:
    """Document observer that triggers report execution on recompute."""

    def __init__(self, doc, report):
        self.doc = doc
        self.report = report

    def slotRecomputedDocument(self, doc):
        if doc != self.doc:
            return
        # Guard against executing after the Report object has been removed.
        # During deletion the observer may not yet be unregistered when the
        # post-removal recompute fires.
        try:
            if not self.report.isAttachedToDocument():
                FreeCAD.removeDocumentObserver(self)
                return
        except RuntimeError:
            FreeCAD.removeDocumentObserver(self)
            return
        self.report.Proxy.execute(self.report)


class _ArchReport:

    def __init__(self, obj):
        self.setProperties(obj)
        # Keep a reference to the host object so helper methods can persist data
        self.obj = obj
        obj.Proxy = self
        self.Type = "ArchReport"
        self.spreadsheet = None
        self.docObserver = None
        self.spreadsheet_current_row = 1  # Internal state for multi-statement reports
        # This list holds the "live" ReportStatement objects for runtime use (UI, execute)
        self.live_statements = []
        # On creation, immediately hydrate the live list from the persistent property
        self.hydrate_live_statements(obj)
        # If no persisted statements were present, create one default statement
        # so the UI shows a starter entry (matching previous behavior).
        if not self.live_statements:
            default_stmt = ReportStatement(description=translate("Arch", "New Statement"))
            self.live_statements.append(default_stmt)
            # Persist the default starter statement so future loads see it
            try:
                self.commit_statements()
            except Exception:
                # Be resilient during early initialization when document context
                # may not be fully available; ignore commit failure.
                pass

    def onDocumentRestored(self, obj):
        """Called after the object properties are restored from a file."""
        # Rebuild the live list of objects from the newly loaded persistent data
        self.obj = obj
        self.hydrate_live_statements(obj)
        self.setProperties(obj)  # This will ensure observer is re-attached

    def hydrate_live_statements(self, obj):
        """(Re)builds the live list of objects from the stored list of dicts."""
        self.live_statements = []
        if hasattr(obj, "Statements") and obj.Statements:
            for s_data in obj.Statements:
                statement = ReportStatement()
                statement.loads(s_data)  # Use existing loads method
                self.live_statements.append(statement)

    def commit_statements(self):
        """
        Persists the live statements to the document object.

        This method serializes the list of live ReportStatement objects
        (self.live_statements) into a list of dictionaries and saves it
        to the persistent obj.Statements property. This is the official
        programmatic way to commit changes.
        """
        self.obj.Statements = [s.dumps() for s in self.live_statements]

    def setProperties(self, obj):
        # Ensure the `Statements` property exists (list of ReportStatement objects)
        if not "Statements" in obj.PropertiesList:
            obj.addProperty(
                "App::PropertyPythonObject",
                "Statements",
                "Report",
                QT_TRANSLATE_NOOP(
                    "App::Property",
                    "The list of SQL statements to execute (managed by the Task Panel)",
                ),
                locked=True,
            )
            obj.Statements = []  # Initialize with an empty list

        if not "Target" in obj.PropertiesList:
            obj.addProperty(
                "App::PropertyLink",
                "Target",
                "Report",
                QT_TRANSLATE_NOOP("App::Property", "The spreadsheet for the results"),
            )
        if not "AutoUpdate" in obj.PropertiesList:
            obj.addProperty(
                "App::PropertyBool",
                "AutoUpdate",
                "Report",
                QT_TRANSLATE_NOOP(
                    "App::Property", "If True, update report when document recomputes"
                ),
            )
            obj.AutoUpdate = True

        self.onChanged(obj, "AutoUpdate")
        # Make the Statements property read-only in the GUI to guide users to the TaskPanel.
        # Mode 1: Read-Only. It does not affect scripting access.
        if FreeCAD.GuiUp:
            obj.setEditorMode("Statements", 1)

    def setReportPropertySpreadsheet(self, sp, obj):
        """Associate a spreadsheet with a report.

        Ensures the spreadsheet has a non-dependent string property
        ``ReportName`` with the report's object name, and sets the
        report's ``Target`` link to the spreadsheet for future writes.

        Parameters
        - sp: the Spreadsheet::Sheet object to associate
        - obj: the report object (proxy owner)
        """
        if not hasattr(sp, "ReportName"):
            sp.addProperty(
                "App::PropertyString",
                "ReportName",
                "Report",
                QT_TRANSLATE_NOOP(
                    "App::Property", "The name of the BIM Report that uses this spreadsheet"
                ),
            )
        sp.ReportName = obj.Name
        obj.Target = sp

    def getSpreadSheet(self, obj, force=False):
        """Find or (optionally) create the spreadsheet associated with a report.

        The association is persisted via the sheet's ``ReportName`` string.

        Parameters
        - obj: the report object
        - force: if True, create a new spreadsheet when none is found
        """
        sp = getattr(self, "spreadsheet", None)
        if sp and getattr(sp, "ReportName", None) == obj.Name:
            return sp

        for o in FreeCAD.ActiveDocument.Objects:
            if o.TypeId == "Spreadsheet::Sheet" and getattr(o, "ReportName", None) == obj.Name:
                self.spreadsheet = o
                return self.spreadsheet

        if force:
            sheet = FreeCAD.ActiveDocument.addObject("Spreadsheet::Sheet", "ReportResult")
            self.setReportPropertySpreadsheet(sheet, obj)
            self.spreadsheet = sheet
            return self.spreadsheet
        else:
            return None

    def onChanged(self, obj, prop):
        if prop == "AutoUpdate":
            if obj.AutoUpdate:
                if getattr(self, "docObserver", None) is None:
                    self.docObserver = _ArchReportDocObserver(FreeCAD.ActiveDocument, obj)
                    FreeCAD.addDocumentObserver(self.docObserver)
            else:
                if getattr(self, "docObserver", None) is not None:
                    FreeCAD.removeDocumentObserver(self.docObserver)
                    self.docObserver = None

            if prop == "Statements":
                # If the persistent data is changed externally (e.g., by a script),
                # re-hydrate the live list to ensure consistency.
                self.hydrate_live_statements(obj)

    def __getstate__(self):
        """Returns minimal internal state of the proxy for serialization."""
        # The main 'Statements' data is persisted on the obj property, not here.
        return {
            "Type": self.Type,
        }

    def __setstate__(self, state):
        """Restores minimal internal state of the proxy from serialized data."""
        self.Type = state.get("Type", "ArchReport")
        self.spreadsheet = None
        self.docObserver = None

    def _write_cell(self, spreadsheet, cell_address, value):
        """Intelligently writes a value to a spreadsheet cell based on its type."""
        # Handle FreeCAD Quantity objects by extracting their raw numerical value.
        if isinstance(value, FreeCAD.Units.Quantity):
            spreadsheet.set(cell_address, str(value.Value))
        elif isinstance(value, (int, float)):
            # Write other numbers directly without quotes for calculations.
            spreadsheet.set(cell_address, str(value))
        elif value is None:
            # Write an empty literal string for None.
            spreadsheet.set(cell_address, "''")
        else:
            # Write all other types (e.g., strings) as literal strings.
            spreadsheet.set(cell_address, f"'{value}")

    def setSpreadsheetData(
        self,
        obj,
        headers,
        data_rows,
        start_row,
        use_description_as_header=False,
        description_text="",
        include_column_names=True,
        add_empty_row_after=False,
        print_results_in_bold=False,
        force=False,
    ):
        """Write headers and rows into the report's spreadsheet, starting from a specific row."""
        sp = obj.Target  # Always use obj.Target directly as it's the explicit link
        if not sp:  # ensure spreadsheet exists, this is an error condition
            FreeCAD.Console.PrintError(
                f"Report '{getattr(obj, 'Label', '')}': No target spreadsheet found.\n"
            )
            return start_row  # Return current row unchanged

        # Determine the effective starting row for this block of data
        current_row = start_row

        # --- "Analyst-First" Header Generation ---
        # Pre-scan the first data row to find the common unit for each column.
        unit_map = {}  # e.g., {1: 'mm', 2: 'mm'}

        if data_rows:
            for i, cell_value in enumerate(data_rows[0]):
                if isinstance(cell_value, FreeCAD.Units.Quantity):
                    # TODO: Replace this with a direct API call when available. The C++ Base::Unit
                    # class has a `getString()` method that returns the simple unit symbol (e.g.,
                    # "mm^2"), but it is not exposed to the Python API. The most reliable workaround
                    # is to stringify the entire Quantity (e.g., "1500.0 mm") and parse the unit
                    # from that string.
                    quantity_str = str(cell_value)
                    parts = quantity_str.split(" ", 1)
                    if len(parts) > 1:
                        unit_map[i] = parts[1]

        # Create the final headers, appending units where found.
        final_headers = []
        for i, header_text in enumerate(headers):
            if i in unit_map:
                final_headers.append(f"{header_text} ({unit_map[i]})")
            else:
                final_headers.append(header_text)

        # Add header for this statement if requested
        if use_description_as_header and description_text.strip():
            # Merging the header across columns (A to last data column)
            last_col_char = chr(ord("A") + len(final_headers) - 1) if final_headers else "A"
            sp.set(f"A{current_row}", f"'{description_text}")
            sp.mergeCells(f"A{current_row}:{last_col_char}{current_row}")
            sp.setStyle(f"A{current_row}", "bold", "add")
            current_row += 1  # Advance row for data or column names

        # Write column names if requested
        if include_column_names and final_headers:
            for col_idx, header_text in enumerate(final_headers):
                sp.set(f"{chr(ord('A') + col_idx)}{current_row}", f"'{header_text}")
            sp.setStyle(
                f'A{current_row}:{chr(ord("A") + len(final_headers) - 1)}{current_row}',
                "bold",
                "add",
            )
            current_row += 1  # Advance row for data

        # Write data rows
        for row_data in data_rows:
            for col_idx, cell_value in enumerate(row_data):
                cell_address = f"{chr(ord('A') + col_idx)}{current_row}"
                self._write_cell(sp, cell_address, cell_value)
                if print_results_in_bold:
                    sp.setStyle(cell_address, "bold", "add")
            current_row += 1  # Advance row for next data row

        # Add empty row if specified
        if add_empty_row_after:
            current_row += 1  # Just increment row, leave it blank

        return current_row  # Return the next available row

    def unsetupObject(self, obj):
        """Remove the document observer so that recomputes after removal do not
        call execute() on a stale reference and create an orphaned spreadsheet.
        """
        if getattr(self, "docObserver", None) is not None:
            FreeCAD.removeDocumentObserver(self.docObserver)
            self.docObserver = None

    def execute(self, obj):
        """Executes all statements and writes the results to the target spreadsheet."""
        if not getattr(obj, "AutoUpdate", True):
            return
        if not self.live_statements:
            return

        sp = self.getSpreadSheet(obj, force=True)
        if not sp:
            FreeCAD.Console.PrintError(
                f"Report '{getattr(obj, 'Label', '')}': No target spreadsheet found.\n"
            )
            return
        # Save column widths before clearing so user adjustments survive recomputes.
        used_range = sp.getUsedRange()
        saved_widths = {}
        if used_range:
            first_col = ord(used_range[0].rstrip("0123456789"))
            last_col = ord(used_range[1].rstrip("0123456789"))
            for col in range(first_col, last_col + 1):
                saved_widths[chr(col)] = sp.getColumnWidth(chr(col))
            sp.clear(f"{used_range[0]}:{used_range[1]}")

        # Reset the row counter for a new report build.
        self.spreadsheet_current_row = 1

        # The execute_pipeline function is a generator that yields the results
        # of each standalone statement or the final result of a pipeline chain.
        for statement, headers, results_data in ArchSql.execute_pipeline(self.live_statements):
            # For each yielded result block, write it to the spreadsheet.
            # The setSpreadsheetData helper already handles all the formatting.
            self.spreadsheet_current_row = self.setSpreadsheetData(
                obj,
                headers,
                results_data,
                start_row=self.spreadsheet_current_row,
                use_description_as_header=statement.use_description_as_header,
                description_text=statement.description,
                include_column_names=statement.include_column_names,
                add_empty_row_after=statement.add_empty_row_after,
                print_results_in_bold=statement.print_results_in_bold,
            )

        for col, width in saved_widths.items():
            sp.setColumnWidth(col, width)
        sp.recompute()
        sp.purgeTouched()

    def __repr__(self):
        """Provides an unambiguous representation for developers."""
        return f"<BIM Report Label='{self.obj.Label}' Statements={len(self.live_statements)}>"

    def __str__(self):
        """
        Provides a detailed, human-readable string representation of the report,
        including the full SQL query for each statement.
        """
        num_statements = len(self.live_statements)
        header = f"BIM Report: '{self.obj.Label}' ({num_statements} statements)"

        lines = [header]
        if not self.live_statements:
            return header

        for i, stmt in enumerate(self.live_statements, 1):
            lines.append("")  # Add a blank line for spacing

            # Build the flag string for the statement header
            flags = []
            if stmt.is_pipelined:
                flags.append("Pipelined")
            if stmt.use_description_as_header:
                flags.append("Header")
            flag_str = f" ({', '.join(flags)})" if flags else ""

            # Add the statement header
            lines.append(f"=== Statement [{i}]: {stmt.description}{flag_str} ===")

            # Add the formatted SQL query
            if stmt.query_string.strip():
                query_lines = stmt.query_string.strip().split("\n")
                for line in query_lines:
                    lines.append(f"    {line}")
            else:
                lines.append("    (No query defined)")

        return "\n".join(lines)


class ViewProviderReport:
    """The ViewProvider for the ArchReport object."""

    def __init__(self, vobj):
        vobj.Proxy = self
        self.vobj = vobj
        vobj.ToggleVisibility = "NoToggleVisibility"

    def getIcon(self):
        return ":/icons/Arch_Schedule.svg"

    def doubleClicked(self, vobj):
        return self.setEdit(vobj, 0)

    def setEdit(self, vobj, mode):
        if mode == 0:
            if FreeCAD.GuiUp:
                panel = ReportTaskPanel(vobj.Object)
                try:
                    FreeCADGui.Control.showDialog(panel)
                except RuntimeError as e:
                    # Avoid raising into the caller (e.g., double click handler)
                    FreeCAD.Console.PrintError(f"Could not open Report editor: {e}\n")
                    return False
            return True
        return False

    def onDelete(self, vobj, subelements):
        """Remove the document observer so that recomputes after removal do not
        call execute() on a stale reference and create an orphaned spreadsheet.
        """
        proxy = getattr(vobj.Object, "Proxy", None)
        if proxy and getattr(proxy, "docObserver", None) is not None:
            FreeCAD.removeDocumentObserver(proxy.docObserver)
            proxy.docObserver = None
        return True

    def attach(self, vobj):
        """Called by the C++ loader when the view provider is rehydrated."""
        self.vobj = vobj
        vobj.ToggleVisibility = "NoToggleVisibility"

    def isShow(self):
        """Always return True so the Tree View does not fade this object."""
        return True

    def claimChildren(self):
        """
        Makes the Target spreadsheet appear as a child in the Tree view,
        by relying on the proxy's getSpreadSheet method for robust lookup.
        """
        obj = self.vobj.Object
        spreadsheet = obj.Proxy.getSpreadSheet(obj)
        return [spreadsheet] if spreadsheet else []

    def dumps(self):
        return None

    def loads(self, state):
        return None


class ReportTaskPanel:
    """Multi-statement task panel for editing a Report.

    Exposes `self.form` as a QWidget so it works with FreeCADGui.Control.showDialog(panel).
    Implements accept() and reject() to save or discard changes.
    """

    # A static blocklist of common, non-queryable properties to exclude
    # from the autocompletion list to reduce noise.
    PROPERTY_BLOCKLIST = {
        "ExpressionEngine",
        "Label2",
        "Proxy",
        "ShapeColor",
        "Visibility",
        "LineColor",
        "LineWidth",
        "PointColor",
        "PointSize",
    }

    def __init__(self, report_obj):
        # Create two top-level widgets so FreeCAD will wrap each into a TaskBox.
        # Box 1 (overview) contains the statements table and management buttons.
        # Box 2 (editor) contains the query editor and options.
        self.obj = report_obj
        self.current_edited_statement_index = -1  # To track which statement is in editor
        self.is_dirty = False  # To track uncommitted changes

        # Overview widget (TaskBox 1)
        self.overview_widget = QtWidgets.QWidget()
        self.overview_widget.setWindowTitle(translate("Arch", "Report Statements"))
        self.statements_overview_widget = self.overview_widget  # preserve older name
        self.statements_overview_layout = QtWidgets.QVBoxLayout(self.statements_overview_widget)

        # Table for statements: Description | Header | Cols | Status
        self.table_statements = QtWidgets.QTableWidget()
        self.table_statements.setColumnCount(5)  # Description, Pipe, Header, Cols, Status
        self.table_statements.setHorizontalHeaderLabels(
            [
                translate("Arch", "Description"),
                translate("Arch", "Pipe"),
                translate("Arch", "Header"),
                translate("Arch", "Cols"),
                translate("Arch", "Status"),
            ]
        )

        # Add informative tooltips to the headers
        self.table_statements.horizontalHeaderItem(2).setToolTip(
            translate("Arch", "A user-defined description for this statement.")
        )
        self.table_statements.horizontalHeaderItem(1).setToolTip(
            translate(
                "Arch",
                "If checked, this statement will use the results of the previous statement as its data source.",
            )
        )
        self.table_statements.horizontalHeaderItem(2).setToolTip(
            translate(
                "Arch",
                "If checked, the Description will be used as a section header in the report.",
            )
        )
        self.table_statements.horizontalHeaderItem(3).setToolTip(
            translate(
                "Arch",
                "If checked, the column names (e.g., 'Label', 'Area') will be included in the report.",
            )
        )
        self.table_statements.horizontalHeaderItem(4).setToolTip(
            translate("Arch", "Indicates the status of the SQL query.")
        )

        # Description stretches, others sized to contents
        self.table_statements.horizontalHeader().setSectionResizeMode(
            0, QtWidgets.QHeaderView.Stretch
        )  # Description
        self.table_statements.horizontalHeader().setSectionResizeMode(
            1, QtWidgets.QHeaderView.ResizeToContents
        )  # Pipe
        self.table_statements.horizontalHeader().setSectionResizeMode(
            2, QtWidgets.QHeaderView.ResizeToContents
        )
        self.table_statements.horizontalHeader().setSectionResizeMode(
            3, QtWidgets.QHeaderView.ResizeToContents
        )
        self.table_statements.horizontalHeader().setSectionResizeMode(
            4, QtWidgets.QHeaderView.ResizeToContents
        )
        self.table_statements.setSelectionBehavior(QtWidgets.QAbstractItemView.SelectRows)
        self.table_statements.setSelectionMode(QtWidgets.QAbstractItemView.SingleSelection)
        self.table_statements.setDragDropMode(QtWidgets.QAbstractItemView.InternalMove)
        self.table_statements.setDragDropOverwriteMode(False)
        # Allow in-place editing of the description with F2, but disable the
        # default double-click editing so we can repurpose it.
        self.table_statements.setEditTriggers(QtWidgets.QAbstractItemView.EditKeyPressed)
        self.table_statements.verticalHeader().sectionMoved.connect(self._on_row_moved)
        self.statements_overview_layout.addWidget(self.table_statements)

        # Template controls for full reports
        self.template_layout = QtWidgets.QHBoxLayout()
        self.template_dropdown = NoScrollHijackComboBox()
        self.template_dropdown.setToolTip(
            translate("Arch", "Load a full report template, replacing all current statements.")
        )
        # Enable per-item tooltips in the dropdown view
        self.template_dropdown.view().setToolTip("")
        self.btn_manage_templates = QtWidgets.QPushButton(translate("Arch", "Manage..."))
        self.btn_manage_templates.setToolTip(
            translate("Arch", "Rename, delete, or edit saved report templates.")
        )
        self.btn_save_template = QtWidgets.QPushButton(translate("Arch", "Save as Template..."))
        self.btn_save_template.setToolTip(
            translate("Arch", "Save the current set of statements as a new report template.")
        )
        self.btn_save_template.setIcon(FreeCADGui.getIcon(":/icons/document-save.svg"))
        self.template_layout.addWidget(self.template_dropdown)
        self.template_layout.addWidget(self.btn_manage_templates)
        self.template_layout.addWidget(self.btn_save_template)
        template_label = QtWidgets.QLabel(translate("Arch", "Report Templates:"))
        self.statements_overview_layout.addWidget(template_label)
        self.statements_overview_layout.addLayout(self.template_layout)

        # Statement Management Buttons
        self.statement_buttons_layout = QtWidgets.QHBoxLayout()
        self.btn_add_statement = QtWidgets.QPushButton(ICON_ADD, translate("Arch", "Add Statement"))
        self.btn_add_statement.setToolTip(
            translate("Arch", "Add a new blank statement to the report.")
        )
        self.btn_remove_statement = QtWidgets.QPushButton(
            ICON_REMOVE, translate("Arch", "Remove Selected")
        )
        self.btn_remove_statement.setToolTip(
            translate("Arch", "Remove the selected statement from the report.")
        )
        self.btn_duplicate_statement = QtWidgets.QPushButton(
            ICON_DUPLICATE, translate("Arch", "Duplicate Selected")
        )
        self.btn_duplicate_statement.setToolTip(
            translate("Arch", "Create a copy of the selected statement.")
        )
        self.btn_edit_selected = QtWidgets.QPushButton(
            ICON_EDIT, translate("Arch", "Edit Selected")
        )
        self.btn_edit_selected.setToolTip(
            translate("Arch", "Load the selected statement into the editor below.")
        )

        self.statement_buttons_layout.addWidget(self.btn_add_statement)
        self.statement_buttons_layout.addWidget(self.btn_remove_statement)
        self.statement_buttons_layout.addWidget(self.btn_duplicate_statement)
        self.statement_buttons_layout.addStretch()
        self.statement_buttons_layout.addWidget(self.btn_edit_selected)
        self.statements_overview_layout.addLayout(self.statement_buttons_layout)

        # Editor widget (TaskBox 2) -- starts collapsed until a statement is selected
        self.editor_widget = QtWidgets.QWidget()
        self.editor_widget.setWindowTitle(translate("Arch", "Statement Editor"))
        # Keep compatibility name used elsewhere
        self.editor_box = self.editor_widget
        self.editor_layout = QtWidgets.QVBoxLayout(self.editor_box)

        # --- Form Layout for Aligned Inputs ---
        self.form_layout = QtWidgets.QFormLayout()
        self.form_layout.setContentsMargins(0, 0, 0, 0)  # Use the main layout's margins

        # Description Row
        self.description_edit = QtWidgets.QLineEdit()
        self.form_layout.addRow(translate("Arch", "Description:"), self.description_edit)

        # Preset Controls Row (widgets are placed in a QHBoxLayout for the second column)
        self.preset_controls_layout = QtWidgets.QHBoxLayout()
        self.query_preset_dropdown = NoScrollHijackComboBox()
        self.query_preset_dropdown.setToolTip(
            translate("Arch", "Load a saved query preset into the editor.")
        )
        # Enable per-item tooltips in the dropdown view
        self.query_preset_dropdown.view().setToolTip("")
        self.btn_manage_queries = QtWidgets.QPushButton(translate("Arch", "Manage..."))
        self.btn_manage_queries.setToolTip(
            translate("Arch", "Rename, delete, or edit your saved query presets.")
        )
        self.btn_save_query_preset = QtWidgets.QPushButton(translate("Arch", "Save..."))
        self.btn_save_query_preset.setToolTip(
            translate("Arch", "Save the current query as a new preset.")
        )
        self.preset_controls_layout.addWidget(self.query_preset_dropdown)
        self.preset_controls_layout.addWidget(self.btn_manage_queries)
        self.preset_controls_layout.addWidget(self.btn_save_query_preset)
        self.form_layout.addRow(translate("Arch", "Query Presets:"), self.preset_controls_layout)

        self.editor_layout.addLayout(self.form_layout)

        # SQL Query editor
        self.sql_label = QtWidgets.QLabel(translate("Arch", "SQL Query:"))
        self.sql_query_edit = SqlQueryEditor()
        self.sql_query_status_label = QtWidgets.QLabel(translate("Arch", "Ready"))
        self.sql_query_status_label.setTextInteractionFlags(QtCore.Qt.TextSelectableByMouse)
        # Enable word wrapping to prevent long error messages from expanding the panel.
        self.sql_query_status_label.setWordWrap(True)
        # Set a dynamic minimum height of 2 lines to prevent layout shifting
        # when the label's content changes from 1 to 2 lines.
        font_metrics = QtGui.QFontMetrics(self.sql_query_status_label.font())
        two_lines_height = 2.5 * font_metrics.height()
        self.sql_query_status_label.setMinimumHeight(two_lines_height)

        # --- Attach Syntax Highlighter ---
        self.sql_highlighter = SqlSyntaxHighlighter(self.sql_query_edit.document())

        # --- Setup Autocompletion ---
        self.completer = QtWidgets.QCompleter(self.sql_query_edit)
        self.completion_model = self._build_completion_model()
        self.completer.setModel(self.completion_model)
        self.completer.setCaseSensitivity(QtCore.Qt.CaseInsensitive)
        # We use a custom keyPressEvent in SqlQueryEditor to handle Tab/Enter
        self.sql_query_edit.setCompleter(self.completer)

        self.editor_layout.addWidget(self.sql_label)
        self.editor_layout.addWidget(self.sql_query_edit)
        self.editor_layout.addWidget(self.sql_query_status_label)

        # --- Debugging Actions (Show Preview, Help) ---
        self.debugging_actions_layout = QtWidgets.QHBoxLayout()

        self.btn_toggle_preview = QtWidgets.QPushButton(translate("Arch", "Show Preview"))
        self.btn_toggle_preview.setIcon(FreeCADGui.getIcon(":/icons/Std_ToggleVisibility.svg"))
        self.btn_toggle_preview.setToolTip(
            translate("Arch", "Show a preview pane to test the current query in isolation.")
        )
        self.btn_toggle_preview.setCheckable(True)  # Make it a toggle button

        self.btn_show_cheatsheet = QtWidgets.QPushButton(translate("Arch", "SQL Cheatsheet"))
        self.btn_show_cheatsheet.setIcon(FreeCADGui.getIcon(":/icons/help-browser.svg"))
        self.btn_show_cheatsheet.setToolTip(
            translate("Arch", "Show a cheatsheet of the supported SQL syntax.")
        )

        self.editor_layout.addLayout(self.debugging_actions_layout)
        self.debugging_actions_layout.addStretch()  # Add stretch first for right-alignment
        self.debugging_actions_layout.addWidget(self.btn_show_cheatsheet)
        self.debugging_actions_layout.addWidget(self.btn_toggle_preview)

        # --- Self-Contained Preview Pane ---
        self.preview_pane = QtWidgets.QWidget()
        preview_pane_layout = QtWidgets.QVBoxLayout(self.preview_pane)
        preview_pane_layout.setContentsMargins(0, 5, 0, 0)  # Add a small top margin

        preview_toolbar_layout = QtWidgets.QHBoxLayout()
        self.btn_refresh_preview = QtWidgets.QPushButton(translate("Arch", "Refresh"))
        self.btn_refresh_preview.setIcon(FreeCADGui.getIcon(":/icons/view-refresh.svg"))
        self.btn_refresh_preview.setToolTip(
            translate("Arch", "Re-run the query and update the preview table.")
        )
        preview_toolbar_layout.addWidget(
            QtWidgets.QLabel(translate("Arch", "<b>Query Results Preview</b>"))
        )
        preview_toolbar_layout.addStretch()
        preview_toolbar_layout.addWidget(self.btn_refresh_preview)

        self.table_preview_results = QtWidgets.QTableWidget()
        self.table_preview_results.setMinimumHeight(150)
        self.table_preview_results.setEditTriggers(
            QtWidgets.QAbstractItemView.NoEditTriggers
        )  # Make read-only
        self.table_preview_results.setSelectionBehavior(QtWidgets.QAbstractItemView.SelectRows)
        preview_pane_layout.addLayout(preview_toolbar_layout)
        preview_pane_layout.addWidget(self.table_preview_results)
        self.editor_layout.addWidget(self.preview_pane)

        # Display Options GroupBox
        self.display_options_group = QtWidgets.QGroupBox(translate("Arch", "Display Options"))
        self.display_options_layout = QtWidgets.QVBoxLayout(self.display_options_group)

        self.chk_is_pipelined = QtWidgets.QCheckBox(translate("Arch", "Use as Pipeline Step"))
        self.chk_is_pipelined.setToolTip(
            translate(
                "Arch",
                "When checked, this statement will use the results of the previous statement as its data source.",
            )
        )
        self.chk_use_description_as_header = QtWidgets.QCheckBox(
            translate("Arch", "Use Description as Section Header")
        )
        self.chk_use_description_as_header.setToolTip(
            translate(
                "Arch",
                "When checked, the statement's description will be written as a merged header row before its results.",
            )
        )
        self.chk_include_column_names = QtWidgets.QCheckBox(
            translate("Arch", "Include Column Names as Headers")
        )
        self.chk_include_column_names.setToolTip(
            translate(
                "Arch",
                "Include the column headers (Label, IfcType, ...) in the spreadsheet output.",
            )
        )
        self.chk_add_empty_row_after = QtWidgets.QCheckBox(translate("Arch", "Add Empty Row After"))
        self.chk_add_empty_row_after.setToolTip(
            translate("Arch", "Insert one empty row after this statement's results.")
        )
        self.chk_print_results_in_bold = QtWidgets.QCheckBox(
            translate("Arch", "Print Results in Bold")
        )
        self.chk_print_results_in_bold.setToolTip(
            translate("Arch", "Render the result cells in bold font for emphasis.")
        )
        self.display_options_layout.addWidget(self.chk_is_pipelined)
        self.display_options_layout.addWidget(self.chk_use_description_as_header)
        self.display_options_layout.addWidget(self.chk_include_column_names)
        self.display_options_layout.addWidget(self.chk_add_empty_row_after)
        self.display_options_layout.addWidget(self.chk_print_results_in_bold)
        self.editor_layout.addWidget(self.display_options_group)

        # --- Commit Actions (Apply, Discard) ---
        self.commit_actions_layout = QtWidgets.QHBoxLayout()
        self.chk_save_and_next = QtWidgets.QCheckBox(translate("Arch", "Save and Next"))
        self.chk_save_and_next.setToolTip(
            translate(
                "Arch",
                "If checked, clicking 'Save' will automatically load the next statement for editing.",
            )
        )
        self.btn_save = QtWidgets.QPushButton(translate("Arch", "Save"))
        self.btn_save.setIcon(FreeCADGui.getIcon(":/icons/document-save.svg"))
        self.btn_save.setToolTip(
            translate("Arch", "Save changes to this statement and close the statement editor.")
        )
        self.btn_discard = QtWidgets.QPushButton(translate("Arch", "Discard"))
        self.btn_discard.setIcon(FreeCADGui.getIcon(":/icons/delete.svg"))
        self.btn_discard.setToolTip(
            translate("Arch", "Discard all changes made in the statement editor.")
        )
        self.commit_actions_layout.addStretch()
        self.commit_actions_layout.addWidget(self.chk_save_and_next)
        self.commit_actions_layout.addWidget(self.btn_discard)
        self.commit_actions_layout.addWidget(self.btn_save)
        self.editor_layout.addLayout(self.commit_actions_layout)

        # Expose form as a list of the two top-level widgets so FreeCAD creates
        # two built-in TaskBox sections. The overview goes first, editor second.
        self.form = [self.overview_widget, self.editor_widget]

        # --- Connections ---
        # Use explicit slots instead of lambda wrappers so Qt's meta-object
        # system can see the call targets and avoid creating anonymous functions.
        self.btn_add_statement.clicked.connect(self._on_add_statement_clicked)
        self.btn_remove_statement.clicked.connect(self._on_remove_selected_statement_clicked)
        self.btn_duplicate_statement.clicked.connect(self._on_duplicate_selected_statement_clicked)  # type: ignore
        self.btn_edit_selected.clicked.connect(self._on_edit_selected_clicked)
        self.table_statements.itemSelectionChanged.connect(self._on_table_selection_changed)
        self.table_statements.itemDoubleClicked.connect(self._on_item_double_clicked)
        self.template_dropdown.activated.connect(self._on_load_report_template)

        # Keep table edits in sync with the runtime statements
        self.table_statements.itemChanged.connect(self._on_table_item_changed)
        self.btn_save_template.clicked.connect(self._on_save_report_template)

        # Enable and connect the preset management buttons
        self.btn_manage_templates.setEnabled(True)
        self.btn_manage_queries.setEnabled(True)
        self.btn_manage_templates.clicked.connect(lambda: self._on_manage_presets("report"))
        self.btn_manage_queries.clicked.connect(lambda: self._on_manage_presets("query"))

        # Connect all editor fields to a generic handler to manage the dirty state.
        self.description_edit.textChanged.connect(self._on_editor_field_changed)
        self.sql_query_edit.textChanged.connect(self._on_editor_sql_changed)
        for checkbox in self.display_options_group.findChildren(QtWidgets.QCheckBox):
            checkbox.stateChanged.connect(self._on_editor_field_changed)
        self.query_preset_dropdown.activated.connect(self._on_load_query_preset)
        self.chk_is_pipelined.stateChanged.connect(self._on_editor_sql_changed)
        self.btn_save_query_preset.clicked.connect(self._on_save_query_preset)

        # Preview and Commit connections
        self.btn_toggle_preview.toggled.connect(self._on_preview_toggled)
        self.btn_refresh_preview.clicked.connect(self._run_and_display_preview)
        self.btn_save.clicked.connect(self.on_save_clicked)
        self.btn_discard.clicked.connect(self.on_discard_clicked)
        self.btn_show_cheatsheet.clicked.connect(self._show_cheatsheet_dialog)

        # Validation Timer for live SQL preview
        # Timer doesn't need a specific QWidget parent here; use no parent.
        self.validation_timer = QtCore.QTimer()
        self.validation_timer.setSingleShot(True)
        self.validation_timer.timeout.connect(self._run_live_validation_for_editor)

        # Store icons for dynamic button changes
        self.icon_show_preview = FreeCADGui.getIcon(":/icons/Std_ToggleVisibility.svg")
        self.icon_hide_preview = FreeCADGui.getIcon(":/icons/Invisible.svg")

        # Initial UI setup
        self._load_and_populate_presets()
        self._populate_table_from_statements()
        # Pass the documentation data to the editor for its tooltips
        api_docs = ArchSql.getSqlApiDocumentation()
        self.sql_query_edit.set_api_documentation(api_docs)
        self.editor_widget.setVisible(False)  # Start with editor hidden
        self._update_ui_for_mode("overview")  # Set initial button states

    def _load_and_populate_presets(self):
        """Loads all presets and populates the UI dropdowns, including tooltips."""

        def _populate_combobox(combobox, preset_type, placeholder_text):
            """Internal helper to load presets and populate a QComboBox."""
            # Load the raw preset data from the backend
            presets = _get_presets(preset_type)

            # Prepare the UI widget
            combobox.clear()
            # The placeholder_text is already translated by the caller
            combobox.addItem(placeholder_text)

            model = combobox.model()

            sorted_presets = sorted(presets.items(), key=lambda item: item[1]["name"])

            # Populate the combobox with the sorted presets
            for filename, preset in sorted_presets:
                # Add the item with its display name and stable filename (as userData)
                combobox.addItem(preset["name"], userData=filename)

                # Get the index of the item that was just added
                index = combobox.count() - 1

                # Access the description from the nested "data" dictionary.
                description = preset["data"].get("description", "").strip()

                if description:
                    item = model.item(index)
                    if item:
                        item.setToolTip(description)

            return presets

        # Use the helper function to populate both dropdowns,
        # ensuring the placeholder strings are translatable.
        self.query_presets = _populate_combobox(
            self.query_preset_dropdown, "query", translate("Arch", "--- Select a Query Preset ---")
        )
        self.report_templates = _populate_combobox(
            self.template_dropdown, "report", translate("Arch", "--- Load a Report Template ---")
        )

    def _on_manage_presets(self, mode):
        """
        Launches the ManagePresetsDialog and refreshes the dropdowns
        when the dialog is closed.
        """
        dialog = ManagePresetsDialog(mode, parent=self.form[0])
        dialog.exec_()

        # Refresh the dropdowns to reflect any changes made
        self._load_and_populate_presets()

    @Slot("QTableWidgetItem")
    def _on_item_double_clicked(self, item):
        """Handles a double-click on an item in the statements table."""
        if item:
            # A double-click is a shortcut for editing the full statement.
            self._start_edit_session(row_index=item.row())

    # --- Statement Management (Buttons and Table Interaction) ---
    def _populate_table_from_statements(self):
        # Avoid emitting itemChanged while we repopulate programmatically
        self.table_statements.blockSignals(True)
        self.table_statements.setRowCount(0)  # Clear existing rows
        # The UI always interacts with the live list of objects from the proxy
        for row_idx, statement in enumerate(self.obj.Proxy.live_statements):
            self.table_statements.insertRow(row_idx)
            # Description (editable text)
            desc_item = QtWidgets.QTableWidgetItem(statement.description)
            desc_item.setFlags(
                desc_item.flags()
                | QtCore.Qt.ItemIsEditable
                | QtCore.Qt.ItemIsSelectable
                | QtCore.Qt.ItemIsEnabled
            )
            desc_item.setToolTip(translate("Arch", "Double-click to edit description in place."))
            self.table_statements.setItem(row_idx, 0, desc_item)

            # Pipe checkbox
            pipe_item = QtWidgets.QTableWidgetItem()
            pipe_item.setFlags(
                QtCore.Qt.ItemIsUserCheckable | QtCore.Qt.ItemIsEnabled | QtCore.Qt.ItemIsSelectable
            )
            pipe_item.setCheckState(
                QtCore.Qt.Checked if statement.is_pipelined else QtCore.Qt.Unchecked
            )
            if row_idx == 0:
                pipe_item.setFlags(
                    pipe_item.flags() & ~QtCore.Qt.ItemIsEnabled
                )  # Disable for first row
                pipe_item.setToolTip(translate("Arch", "The first statement cannot be pipelined."))
            else:
                pipe_item.setToolTip(
                    translate(
                        "Arch", "Toggles whether to use the previous statement's results as input"
                    )
                )
            self.table_statements.setItem(row_idx, 1, pipe_item)

            # Header checkbox
            header_item = QtWidgets.QTableWidgetItem()
            header_item.setFlags(
                QtCore.Qt.ItemIsUserCheckable | QtCore.Qt.ItemIsEnabled | QtCore.Qt.ItemIsSelectable
            )
            header_item.setCheckState(
                QtCore.Qt.Checked if statement.use_description_as_header else QtCore.Qt.Unchecked
            )
            header_item.setToolTip(
                translate(
                    "Arch",
                    "Toggles whether to use this statement's Description as a section header",
                )
            )
            self.table_statements.setItem(row_idx, 2, header_item)

            # Cols checkbox (Include Column Names)
            cols_item = QtWidgets.QTableWidgetItem()
            cols_item.setFlags(
                QtCore.Qt.ItemIsUserCheckable | QtCore.Qt.ItemIsEnabled | QtCore.Qt.ItemIsSelectable
            )
            cols_item.setCheckState(
                QtCore.Qt.Checked if statement.include_column_names else QtCore.Qt.Unchecked
            )
            cols_item.setToolTip(
                translate(
                    "Arch",
                    "Toggles whether to include this statement's column names in the report",
                )
            )
            self.table_statements.setItem(row_idx, 3, cols_item)

            # Status Item (Icon + Tooltip) - read-only
            status_icon, status_tooltip = self._get_status_icon_and_tooltip(statement)
            status_item = QtWidgets.QTableWidgetItem()
            status_item.setIcon(status_icon)
            status_item.setToolTip(status_tooltip)
            # Display the object count next to the icon for valid queries.
            if statement._validation_status in ("OK", "0_RESULTS"):
                status_item.setText(str(statement._validation_count))
                # Align the text to the right for better visual separation.
                status_item.setTextAlignment(QtCore.Qt.AlignRight | QtCore.Qt.AlignVCenter)
            status_item.setFlags(status_item.flags() & ~QtCore.Qt.ItemIsEditable)  # Make read-only
            self.table_statements.setItem(row_idx, 4, status_item)

        # After populating all rows, trigger a validation for all statements.
        # This ensures the counts and statuses are up-to-date when the panel opens.
        statements = self.obj.Proxy.live_statements
        for index, statement in enumerate(statements):
            statement.validate_and_update_status(statements, index)
            self._update_table_row_status(index, statement)

        # Re-enable signals after population so user edits are handled
        self.table_statements.blockSignals(False)

    # --- Explicit Qt Slot Wrappers ---
    @Slot()
    def _on_add_statement_clicked(self):
        """Slot wrapper for the Add button (clicked)."""
        # Default behavior: create a new statement but do not open editor.
        self._add_statement(start_editing=False)

    @Slot()
    def _on_remove_selected_statement_clicked(self):
        """Slot wrapper for the Remove button (clicked)."""
        self._remove_selected_statement()

    @Slot()
    def _on_duplicate_selected_statement_clicked(self):
        """Slot wrapper for the Duplicate button (clicked)."""
        self._duplicate_selected_statement()

    @Slot()
    def _on_edit_selected_clicked(self):
        """Slot wrapper for the Edit Selected button (clicked)."""
        # Delegate to _start_edit_session() which will find the selection if no
        # explicit row_index is given.
        self._start_edit_session()

    def _on_table_item_changed(self, item):
        """Synchronize direct table edits (description and checkboxes) back into the runtime statement."""
        row = item.row()
        col = item.column()
        if row < 0 or row >= len(self.obj.Proxy.live_statements):
            return
        stmt = self.obj.Proxy.live_statements[row]

        if col == 0:  # Description
            new_text = item.text()
            if stmt.description != new_text:
                stmt.description = new_text
                self._set_dirty(True)

        elif col == 1:  # Pipe checkbox
            is_checked = item.checkState() == QtCore.Qt.Checked
            if stmt.is_pipelined != is_checked:
                stmt.is_pipelined = is_checked
                self._set_dirty(True)
                # Re-validate the editor if its context has changed
                if self.current_edited_statement_index != -1:
                    self._run_live_validation_for_editor()

        elif col == 2:  # Header checkbox
            is_checked = item.checkState() == QtCore.Qt.Checked
            if stmt.use_description_as_header != is_checked:
                stmt.use_description_as_header = is_checked
                self._set_dirty(True)

        elif col == 3:  # Cols checkbox
            is_checked = item.checkState() == QtCore.Qt.Checked
            if stmt.include_column_names != is_checked:
                stmt.include_column_names = is_checked
                self._set_dirty(True)

    def _on_row_moved(self, logical_index, old_visual_index, new_visual_index):
        """Handles the reordering of statements via drag-and-drop."""
        # The visual index is what the user sees. The logical index is tied to the original sort.
        # When a row is moved, we need to map the visual change back to our data model.

        # Pop the item from its original position in the data model.
        moving_statement = self.obj.Proxy.live_statements.pop(old_visual_index)
        # Insert it into its new position.
        self.obj.Proxy.live_statements.insert(new_visual_index, moving_statement)

        self._set_dirty(True)
        # After reordering the data, we must repopulate the table to ensure
        # everything is visually correct and consistent, especially the disabled
        # "Pipe" checkbox on the new first row.
        self._populate_table_from_statements()
        # Restore the selection to the row that was just moved.
        self.table_statements.selectRow(new_visual_index)

    def _add_statement(self, start_editing=False):
        """Creates a new statement, adds it to the report, and optionally starts editing it."""
        # Create the new statement object and add it to the live list.
        new_statement = ReportStatement(
            description=translate(
                "Arch", f"New Statement {len(self.obj.Proxy.live_statements) + 1}"
            )
        )
        self.obj.Proxy.live_statements.append(new_statement)

        # Refresh the entire overview table to show the new row.
        self._populate_table_from_statements()

        # Validate the new (empty) statement to populate its status.
        new_statement.validate_and_update_status()

        new_row_index = len(self.obj.Proxy.live_statements) - 1
        if start_editing:
            self._start_edit_session(row_index=new_row_index)
        else:
            self.table_statements.selectRow(new_row_index)

        self._set_dirty(True)

    def _remove_selected_statement(self):
        selected_rows = self.table_statements.selectionModel().selectedRows()
        if not selected_rows:
            return

        row_to_remove = selected_rows[0].row()
        description_to_remove = self.table_statements.item(row_to_remove, 0).text()

        if (
            QtWidgets.QMessageBox.question(
                None,
                translate("Arch", "Remove Statement"),
                translate(
                    "Arch", f"Are you sure you want to remove statement '{description_to_remove}'?"
                ),
                QtWidgets.QMessageBox.Yes | QtWidgets.QMessageBox.No,
            )
            == QtWidgets.QMessageBox.Yes
        ):
            self.obj.Proxy.live_statements.pop(row_to_remove)
            self._set_dirty(True)
            self._populate_table_from_statements()
            self._end_edit_session()  # Close editor and reset selection

    def _duplicate_selected_statement(self):
        """Duplicates the selected statement without opening the editor."""
        selected_rows = self.table_statements.selectionModel().selectedRows()
        if not selected_rows:
            return

        row_to_duplicate = selected_rows[0].row()
        original = self.obj.Proxy.live_statements[row_to_duplicate]

        duplicated = ReportStatement()
        duplicated.loads(original.dumps())
        duplicated.description = translate("Arch", f"Copy of {original.description}")

        self.obj.Proxy.live_statements.insert(row_to_duplicate + 1, duplicated)
        self._set_dirty(True)
        self._populate_table_from_statements()
        duplicated.validate_and_update_status()

        # New behavior: Just select the newly created row. Do NOT open the editor.
        self.table_statements.selectRow(row_to_duplicate + 1)

    def _select_statement_in_table(self, row_idx):
        # Select the row visually and trigger the new edit session
        self.table_statements.selectRow(row_idx)
        # This method should ONLY select, not start an edit session.

    # --- Editor (Box 2) Management ---

    def _load_statement_to_editor(self, statement: ReportStatement):
        # Disable/enable the pipeline checkbox based on row index
        is_first_statement = self.current_edited_statement_index == 0
        self.chk_is_pipelined.setEnabled(not is_first_statement)
        if is_first_statement:
            # Ensure the first statement can never be pipelined
            statement.is_pipelined = False

        self.description_edit.setText(statement.description)
        self.sql_query_edit.setPlainText(statement.query_string)
        self.chk_is_pipelined.setChecked(statement.is_pipelined)
        self.chk_use_description_as_header.setChecked(statement.use_description_as_header)
        self.chk_include_column_names.setChecked(statement.include_column_names)
        self.chk_add_empty_row_after.setChecked(statement.add_empty_row_after)
        self.chk_print_results_in_bold.setChecked(statement.print_results_in_bold)

        # We must re-run validation here because the context may have changed
        self._run_live_validation_for_editor()

    def _save_current_editor_state_to_statement(self):
        if self.current_edited_statement_index != -1 and self.current_edited_statement_index < len(
            self.obj.Proxy.live_statements
        ):
            statement = self.obj.Proxy.live_statements[self.current_edited_statement_index]
            statement.description = self.description_edit.text()
            statement.query_string = self.sql_query_edit.toPlainText()
            statement.use_description_as_header = self.chk_use_description_as_header.isChecked()
            statement.include_column_names = self.chk_include_column_names.isChecked()
            statement.add_empty_row_after = self.chk_add_empty_row_after.isChecked()
            statement.print_results_in_bold = self.chk_print_results_in_bold.isChecked()
            statement.validate_and_update_status(
                self.obj.Proxy.live_statements, self.current_edited_statement_index
            )
            self._update_table_row_status(
                self.current_edited_statement_index, statement
            )  # Refresh table status

    def _on_editor_sql_changed(self):
        """Handles text changes in the SQL editor, triggering validation."""
        self._on_editor_field_changed()  # Mark as dirty
        # Immediately switch to a neutral "Typing..." state to provide
        # instant feedback and hide any previous validation messages.
        self.sql_query_status_label.setText(translate("Arch", "<i>Typing...</i>"))
        self.sql_query_status_label.setStyleSheet("color: gray;")
        # Start (or restart) the timer for the full validation.
        self.validation_timer.start(500)

    def _on_editor_field_changed(self, *args):
        """A generic slot that handles any change in an editor field to mark it as dirty.

        This method is connected to multiple signal signatures (textChanged -> str,
        stateChanged -> int). Leaving it undecorated (or accepting *args) keeps it
        flexible so Qt can call it with varying argument lists.
        """
        self._set_dirty(True)

    @Slot(int)
    def _on_load_query_preset(self, index):
        """Handles the selection of a query preset from the dropdown."""
        if index == 0:  # Ignore the placeholder item
            return

        filename = self.query_preset_dropdown.itemData(index)
        preset_data = self.query_presets.get(filename, {}).get("data")

        if not preset_data:
            FreeCAD.Console.PrintError(
                f"BIM Report: Could not load data for query preset with filename '{filename}'.\n"
            )
            self.query_preset_dropdown.setCurrentIndex(0)
            return

        # Confirm before overwriting existing text
        if self.sql_query_edit.toPlainText().strip():
            reply = QtWidgets.QMessageBox.question(
                None,
                translate("Arch", "Overwrite Query?"),
                translate(
                    "Arch",
                    "Loading a preset will overwrite the current text in the query editor. Continue?",
                ),
                QtWidgets.QMessageBox.Yes | QtWidgets.QMessageBox.No,
                QtWidgets.QMessageBox.No,
            )
            if reply == QtWidgets.QMessageBox.No:
                self.query_preset_dropdown.setCurrentIndex(0)  # Reset dropdown
                return

        if "query" in preset_data:
            self.sql_query_edit.setPlainText(preset_data["query"])

        # Reset dropdown to act as a one-shot action button
        self.query_preset_dropdown.setCurrentIndex(0)

    @Slot()
    def _on_save_query_preset(self):
        """Saves the current query text as a new user preset."""
        current_query = self.sql_query_edit.toPlainText().strip()
        if not current_query:
            QtWidgets.QMessageBox.warning(
                None,
                translate("Arch", "Empty Query"),
                translate("Arch", "Cannot save an empty query as a preset."),
            )
            return

        preset_name, ok = QtWidgets.QInputDialog.getText(
            None, translate("Arch", "Save Query Preset"), translate("Arch", "Preset Name:")
        )
        if ok and preset_name:
            # The data payload does not include the 'name' key; _save_preset adds it.
            preset_data = {"description": "User-defined query preset.", "query": current_query}
            _save_preset("query", preset_name, preset_data)
            self._load_and_populate_presets()  # Refresh the dropdown with the new preset

    @Slot(int)
    def _on_load_report_template(self, index):
        """Handles the selection of a full report template from the dropdown."""
        if index == 0:
            return

        filename = self.template_dropdown.itemData(index)
        template_data = self.report_templates.get(filename, {}).get("data")

        if not template_data:
            FreeCAD.Console.PrintError(
                f"BIM Report: Could not load data for template with filename '{filename}'.\n"
            )
            self.template_dropdown.setCurrentIndex(0)
            return

        if self.obj.Proxy.live_statements:
            reply = QtWidgets.QMessageBox.question(
                None,
                translate("Arch", "Overwrite Report?"),
                translate(
                    "Arch",
                    "Loading a template will replace all current statements in this report. Continue?",
                ),
                QtWidgets.QMessageBox.Yes | QtWidgets.QMessageBox.No,
                QtWidgets.QMessageBox.No,
            )
            if reply == QtWidgets.QMessageBox.No:
                self.template_dropdown.setCurrentIndex(0)
                return

        if "statements" in template_data:
            # Rebuild the live list from the template data
            self.obj.Proxy.live_statements = []
            for s_data in template_data["statements"]:
                statement = ReportStatement()
                statement.loads(s_data)
                self.obj.Proxy.live_statements.append(statement)

            self._populate_table_from_statements()

            # Terminate any active editing session, as loading a template invalidates it. This
            # correctly resets the entire UI state.
            self._end_edit_session()

            self._set_dirty(True)

        self.template_dropdown.setCurrentIndex(0)

    @Slot()
    def _on_save_report_template(self):
        """Saves the current set of statements as a new report template."""
        if not self.obj.Proxy.live_statements:
            QtWidgets.QMessageBox.warning(
                None,
                translate("Arch", "Empty Report"),
                translate("Arch", "Cannot save an empty report as a template."),
            )
            return

        template_name, ok = QtWidgets.QInputDialog.getText(
            None, translate("Arch", "Save Report Template"), translate("Arch", "Template Name:")
        )
        if ok and template_name:
            # The data payload does not include the 'name' key.
            template_data = {
                "description": "User-defined report template.",
                "statements": [s.dumps() for s in self.obj.Proxy.live_statements],
            }
            _save_preset("report", template_name, template_data)
            self._load_and_populate_presets()  # Refresh the template dropdown

    def _run_live_validation_for_editor(self):
        """
        Runs live validation for the query in the editor, providing
        contextual feedback if the statement is part of a pipeline.
        This method does NOT modify the underlying statement object.
        """
        if self.current_edited_statement_index == -1:
            return

        current_query = self.sql_query_edit.toPlainText()
        is_pipelined = self.chk_is_pipelined.isChecked()

        # Create a temporary, in-memory statement object for validation.
        # This prevents mutation of the real data model.
        temp_statement = ReportStatement()

        source_objects = None
        input_count_str = ""

        if is_pipelined and self.current_edited_statement_index > 0:
            preceding_statements = self.obj.Proxy.live_statements[
                : self.current_edited_statement_index
            ]
            source_objects = ArchSql._execute_pipeline_for_objects(preceding_statements)
            input_count = len(source_objects)
            input_count_str = translate("Arch", f" (from {input_count} in pipeline)")

        count, error = ArchSql.count(current_query, source_objects=source_objects)

        # --- Update the UI display using the validation results ---
        if not error and count > 0:
            temp_statement._validation_status = "OK"
            temp_statement._validation_message = f"{translate('Arch', 'Found')} {count} {translate('Arch', 'objects')}{input_count_str}."
        elif not error and count == 0:
            temp_statement._validation_status = "0_RESULTS"
            # The message for 0 results is more of a warning than a success.
            temp_statement._validation_message = (
                f"{translate('Arch', 'Query is valid but found 0 objects')}{input_count_str}."
            )
        elif error == "INCOMPLETE":
            temp_statement._validation_status = "INCOMPLETE"
            temp_statement._validation_message = translate("Arch", "Query is incomplete")
        else:  # An actual error occurred
            temp_statement._validation_status = "ERROR"
            temp_statement._validation_message = f"{error}{input_count_str}"

        self._update_editor_status_display(temp_statement)

    def _update_editor_status_display(self, statement: ReportStatement):
        # Update the status label (below SQL editor) in Box 2
        # The "Typing..." state is now handled instantly by _on_editor_sql_changed.
        # This method only handles the final states (Incomplete, Error, 0, OK).
        if statement._validation_status == "INCOMPLETE":
            self.sql_query_status_label.setText(f"⚠️ {statement._validation_message}")
            self.sql_query_status_label.setStyleSheet("color: orange;")
        elif statement._validation_status == "ERROR":
            self.sql_query_status_label.setText(f"❌ {statement._validation_message}")
            self.sql_query_status_label.setStyleSheet("color: red;")
        elif statement._validation_status == "0_RESULTS":
            self.sql_query_status_label.setText(f"⚠️ {statement._validation_message}")
            self.sql_query_status_label.setStyleSheet("color: orange;")
        else:  # OK or Ready
            self.sql_query_status_label.setText(f"✅ {statement._validation_message}")
            self.sql_query_status_label.setStyleSheet("color: green;")

        # The preview button should only be enabled if the query is valid and
        # can be executed (even if it returns 0 results).
        is_executable = statement._validation_status in ("OK", "0_RESULTS")
        self.btn_toggle_preview.setEnabled(is_executable)

    def _update_table_row_status(self, row_idx, statement: ReportStatement):
        """Updates the status icon/tooltip and other data in the QTableWidget for a given row."""
        if row_idx < 0 or row_idx >= self.table_statements.rowCount():
            return

        # Correct Column Mapping:
        # 0: Description
        # 1: Pipe
        # 2: Header
        # 3: Cols
        # 4: Status

        # Update all cells in the row to be in sync with the statement object.
        # This is safer than assuming which property might have changed.
        self.table_statements.item(row_idx, 0).setText(statement.description)
        self.table_statements.item(row_idx, 1).setCheckState(
            QtCore.Qt.Checked if statement.is_pipelined else QtCore.Qt.Unchecked
        )
        self.table_statements.item(row_idx, 2).setCheckState(
            QtCore.Qt.Checked if statement.use_description_as_header else QtCore.Qt.Unchecked
        )
        self.table_statements.item(row_idx, 3).setCheckState(
            QtCore.Qt.Checked if statement.include_column_names else QtCore.Qt.Unchecked
        )

        status_item = self.table_statements.item(row_idx, 4)
        if status_item:
            status_icon, status_tooltip = self._get_status_icon_and_tooltip(statement)
            status_item.setIcon(status_icon)
            status_item.setToolTip(status_tooltip)
            # Update the text as well
            if statement._validation_status in ("OK", "0_RESULTS"):
                status_item.setText(str(statement._validation_count))
            else:
                status_item.setText("")  # Clear the text for error/incomplete states

    def _get_status_icon_and_tooltip(self, statement: ReportStatement):
        # Helper to get appropriate icon and tooltip for table status column
        status = statement._validation_status
        message = statement._validation_message

        if status == "OK":
            return ICON_STATUS_OK, message
        elif status == "0_RESULTS":
            return ICON_STATUS_WARN, message
        elif status == "ERROR":
            return ICON_STATUS_ERROR, message
        elif status == "INCOMPLETE":
            return ICON_STATUS_INCOMPLETE, translate("Arch", "Query incomplete or typing...")
        return QtGui.QIcon(), translate("Arch", "Ready")  # Default/initial state

    def _set_dirty(self, dirty_state):
        """Updates the UI to show if there are uncommitted changes."""
        if self.is_dirty == dirty_state:
            return
        self.is_dirty = dirty_state
        title = translate("Arch", "Report Statements")
        if self.is_dirty:
            title += " *"
        self.overview_widget.setWindowTitle(title)

    def _show_cheatsheet_dialog(self):
        """Gets the API documentation and displays it in a dialog."""
        api_data = ArchSql.getSqlApiDocumentation()
        dialog = CheatsheetDialog(api_data, parent=self.editor_widget)
        dialog.exec_()

    def _build_completion_model(self):
        """
        Builds the master list of words for the autocompleter.

        This method gets raw data from the SQL engine and then applies all
        UI-specific formatting, such as combining keywords into phrases and
        adding trailing spaces for a better user experience.
        """
        # 1. Get the set of keywords that should NOT get a trailing space.
        no_space_keywords = ArchSql.getSqlKeywords(kind="no_space")

        # 2. Get the raw list of all individual keywords.
        raw_keywords = set(ArchSql.getSqlKeywords())

        # 3. Define UI-specific phrases and their components.
        smart_clauses = {"GROUP BY ": ("GROUP", "BY"), "ORDER BY ": ("ORDER", "BY")}

        # 4. Build the final set of completion words.
        all_words = set()

        # Add the smart phrases directly.
        all_words.update(smart_clauses.keys())

        # Get the individual components of the smart phrases to avoid adding them twice.
        words_to_skip = {word for components in smart_clauses.values() for word in components}

        for word in raw_keywords:
            if word in words_to_skip:
                continue

            if word in no_space_keywords:
                all_words.add(word)  # Add without a space
            else:
                all_words.add(word + " ")  # Add with a space by default

        # 5. Add all unique property names from the document (without spaces).
        if FreeCAD.ActiveDocument:
            property_names = set()
            for obj in FreeCAD.ActiveDocument.Objects:
                for prop_name in obj.PropertiesList:
                    if prop_name not in self.PROPERTY_BLOCKLIST:
                        property_names.add(prop_name)
            all_words.update(property_names)

        # 6. Return a sorted model for the completer.
        return QtCore.QStringListModel(sorted(list(all_words)))

    def _update_ui_for_mode(self, mode):
        """Centralizes enabling/disabling of UI controls based on the current mode."""
        if mode == "editing":
            # In edit mode, disable overview actions to prevent conflicts
            self.btn_add_statement.setEnabled(False)
            self.btn_remove_statement.setEnabled(False)
            self.btn_duplicate_statement.setEnabled(False)
            self.btn_edit_selected.setEnabled(False)
            self.template_dropdown.setEnabled(False)
            self.btn_save_template.setEnabled(False)
            self.table_statements.setEnabled(False)
        else:  # "overview" mode
            # In overview mode, re-enable controls
            self.btn_add_statement.setEnabled(True)
            self.btn_remove_statement.setEnabled(True)
            self.btn_duplicate_statement.setEnabled(True)
            self.template_dropdown.setEnabled(True)
            self.btn_save_template.setEnabled(True)
            self.table_statements.setEnabled(True)
            # The "Edit" button state depends on whether a row is selected
            self._on_table_selection_changed()

    def _on_table_selection_changed(self):
        """Slot for selection changes in the overview table."""
        # This method's only job is to enable the "Edit" button if a row is selected.
        has_selection = bool(self.table_statements.selectionModel().selectedRows())
        self.btn_edit_selected.setEnabled(has_selection)

    def _start_edit_session(self, row_index=None):
        """Loads a statement into the editor and displays it."""
        if row_index is None:
            selected_rows = self.table_statements.selectionModel().selectedRows()
            if not selected_rows:
                return
            row_index = selected_rows[0].row()

        # Explicitly hide the preview pane and reset the toggle when starting a new session.
        self.preview_pane.setVisible(False)
        self.btn_toggle_preview.setChecked(False)

        self.current_edited_statement_index = row_index
        statement = self.obj.Proxy.live_statements[row_index]

        # Load data into the editor
        self._load_statement_to_editor(statement)

        # Show editor and set focus
        self.editor_widget.setVisible(True)
        self.sql_query_edit.setFocus()
        self._update_ui_for_mode("editing")

        # Initially disable the preview button until the first validation confirms
        # that the query is executable.
        self.btn_toggle_preview.setEnabled(False)

    def _end_edit_session(self):
        """Hides the editor and restores the overview state."""
        self.editor_widget.setVisible(False)
        self.preview_pane.setVisible(False)  # Also hide preview if it was open
        self.btn_toggle_preview.setChecked(False)  # Ensure toggle is reset
        self.current_edited_statement_index = -1
        self._update_ui_for_mode("overview")
        self.table_statements.setFocus()

    def _commit_changes(self):
        """Saves the data from the editor back to the live statement object."""
        if self.current_edited_statement_index == -1:
            return

        statement = self.obj.Proxy.live_statements[self.current_edited_statement_index]
        statement.description = self.description_edit.text()
        statement.query_string = self.sql_query_edit.toPlainText()
        statement.is_pipelined = self.chk_is_pipelined.isChecked()
        statement.use_description_as_header = self.chk_use_description_as_header.isChecked()
        statement.include_column_names = self.chk_include_column_names.isChecked()
        statement.add_empty_row_after = self.chk_add_empty_row_after.isChecked()
        statement.print_results_in_bold = self.chk_print_results_in_bold.isChecked()

        statement.validate_and_update_status(
            self.obj.Proxy.live_statements, self.current_edited_statement_index
        )
        self._update_table_row_status(self.current_edited_statement_index, statement)
        self._set_dirty(True)

    def on_save_clicked(self):
        """Saves changes and either closes the editor or adds a new statement."""
        # First, always commit the changes from the current edit session.
        self._commit_changes()

        if self.chk_save_and_next.isChecked():
            # If the checkbox is checked, the "Next" action is to add a new
            # blank statement. The _add_statement helper already handles
            # creating the statement and opening it in the editor.
            self._add_statement(start_editing=True)
        else:
            # The default action is to simply close the editor.
            self._end_edit_session()

    def on_discard_clicked(self):
        """Discards changes and closes the editor."""
        self._end_edit_session()

    @Slot(bool)
    def _on_preview_toggled(self, checked):
        """Shows or hides the preview pane and updates the toggle button's appearance."""
        if checked:
            self.btn_toggle_preview.setText(translate("Arch", "Hide Preview"))
            self.btn_toggle_preview.setIcon(self.icon_hide_preview)
            self.preview_pane.setVisible(True)
            self.btn_refresh_preview.setVisible(True)
            self._run_and_display_preview()
        else:
            self.btn_toggle_preview.setText(translate("Arch", "Show Preview"))
            self.btn_toggle_preview.setIcon(self.icon_show_preview)
            self.preview_pane.setVisible(False)
            self.btn_refresh_preview.setVisible(False)

    def _run_and_display_preview(self):
        """Executes the query in the editor and populates the preview table, respecting the pipeline context."""
        query = self.sql_query_edit.toPlainText().strip()
        is_pipelined = self.chk_is_pipelined.isChecked()

        if not self.preview_pane.isVisible():
            return
        if not query:
            self.table_preview_results.clear()
            self.table_preview_results.setRowCount(0)
            self.table_preview_results.setColumnCount(0)
            return

        source_objects = None
        if is_pipelined and self.current_edited_statement_index > 0:
            preceding_statements = self.obj.Proxy.live_statements[
                : self.current_edited_statement_index
            ]
            source_objects = ArchSql._execute_pipeline_for_objects(preceding_statements)

        try:
            # Run the preview with the correct context.
            headers, data_rows, _ = ArchSql._run_query(
                query, mode="full_data", source_objects=source_objects
            )

            self.table_preview_results.clear()
            self.table_preview_results.setColumnCount(len(headers))
            self.table_preview_results.setHorizontalHeaderLabels(headers)
            self.table_preview_results.setRowCount(len(data_rows))

            for row_idx, row_data in enumerate(data_rows):
                for col_idx, cell_value in enumerate(row_data):
                    if isinstance(cell_value, FreeCAD.Units.Quantity):
                        display_text = cell_value.toStr()
                    else:
                        display_text = str(cell_value)
                    item = QtWidgets.QTableWidgetItem(display_text)
                    self.table_preview_results.setItem(row_idx, col_idx, item)
            self.table_preview_results.horizontalHeader().setSectionResizeMode(
                QtWidgets.QHeaderView.Interactive
            )

        except (ArchSql.SqlEngineError, ArchSql.BimSqlSyntaxError) as e:
            # Error handling remains the same
            self.table_preview_results.clear()
            self.table_preview_results.setRowCount(1)
            self.table_preview_results.setColumnCount(1)
            self.table_preview_results.setHorizontalHeaderLabels(["Query Error"])
            error_item = QtWidgets.QTableWidgetItem(f"❌ {str(e)}")
            error_item.setForeground(QtGui.QColor("red"))
            self.table_preview_results.setItem(0, 0, error_item)
            self.table_preview_results.horizontalHeader().setSectionResizeMode(
                0, QtWidgets.QHeaderView.Stretch
            )

    # --- Dialog Acceptance / Rejection ---

    def accept(self):
        """Saves changes from UI to Report object and triggers recompute."""
        # First, check if there is an active, unsaved edit session.
        if self.current_edited_statement_index != -1:
            reply = QtWidgets.QMessageBox.question(
                None,
                translate("Arch", "Unsaved Changes"),
                translate(
                    "Arch",
                    "You have unsaved changes in the statement editor. Do you want to save them before closing?",
                ),
                QtWidgets.QMessageBox.Save
                | QtWidgets.QMessageBox.Discard
                | QtWidgets.QMessageBox.Cancel,
                QtWidgets.QMessageBox.Save,
            )

            if reply == QtWidgets.QMessageBox.Save:
                self._commit_changes()
            elif reply == QtWidgets.QMessageBox.Cancel:
                return  # Abort the close operation entirely.
            # If Discard, do nothing and proceed with closing.

        # This is the "commit" step: persist the live statements to the document object.
        self.obj.Proxy.commit_statements()

        # Trigger a recompute to run the report and mark the document as modified.
        # This will now run the final, correct pipeline.
        FreeCAD.ActiveDocument.recompute()

        # Quality of life: open the target spreadsheet to show the results.
        spreadsheet = self.obj.Target
        if spreadsheet:
            FreeCADGui.ActiveDocument.setEdit(spreadsheet.Name, 0)

        # Close the task panel.
        try:
            FreeCADGui.Control.closeDialog()
        except Exception as e:
            FreeCAD.Console.PrintLog(f"Could not close Report Task Panel: {e}\n")
        self._set_dirty(False)

    def reject(self):
        """Closes dialog without saving changes to the Report object."""
        # Revert changes by not writing to self.obj.Statements
        # Discard live changes by re-hydrating from the persisted property
        self.obj.Proxy.hydrate_live_statements(self.obj)
        self._set_dirty(False)
        # Close the task panel when GUI is available
        try:
            FreeCADGui.Control.closeDialog()
        except Exception as e:
            # This is a defensive catch. If closing the dialog fails for any reason
            # (e.g., it was already closed), we log the error but do not crash.
            FreeCAD.Console.PrintLog(f"Could not close Report Task Panel: {e}\n")


if FreeCAD.GuiUp:
    from PySide.QtGui import QDesktopServices
    from PySide.QtCore import QUrl

    class ManagePresetsDialog(QtWidgets.QDialog):
        """A dialog for managing user-created presets (rename, delete, edit source)."""

        def __init__(self, mode, parent=None):
            super().__init__(parent)
            self.mode = mode  # 'query' or 'report'
            self.setWindowTitle(translate("Arch", f"Manage {mode.capitalize()} Presets"))
            self.setMinimumSize(500, 400)

            # --- UI Layout ---
            self.layout = QtWidgets.QVBoxLayout(self)

            self.preset_list = QtWidgets.QListWidget()
            self.layout.addWidget(self.preset_list)

            self.buttons_layout = QtWidgets.QHBoxLayout()
            self.btn_rename = QtWidgets.QPushButton(translate("Arch", "Rename..."))
            self.btn_delete = QtWidgets.QPushButton(translate("Arch", "Delete"))
            self.btn_edit_source = QtWidgets.QPushButton(translate("Arch", "Edit Source..."))
            self.btn_close = QtWidgets.QPushButton(translate("Arch", "Close"))

            self.buttons_layout.addWidget(self.btn_rename)
            self.buttons_layout.addWidget(self.btn_delete)
            self.buttons_layout.addStretch()
            self.buttons_layout.addWidget(self.btn_edit_source)
            self.layout.addLayout(self.buttons_layout)
            self.layout.addWidget(self.btn_close)

            # --- Connections ---
            self.btn_close.clicked.connect(self.accept)
            self.preset_list.itemSelectionChanged.connect(self._on_selection_changed)
            self.btn_rename.clicked.connect(self._on_rename)
            self.btn_delete.clicked.connect(self._on_delete)
            self.btn_edit_source.clicked.connect(self._on_edit_source)

            # --- Initial State ---
            self._populate_list()
            self._on_selection_changed()  # Set initial button states

        def _populate_list(self):
            """Fills the list widget with system and user presets."""
            self.preset_list.clear()
            self.presets = _get_presets(self.mode)

            # Sort by display name for consistent UI order
            sorted_presets = sorted(self.presets.items(), key=lambda item: item[1]["name"])

            for filename, preset_data in sorted_presets:
                item = QtWidgets.QListWidgetItem()
                display_text = preset_data["name"]

                if preset_data["is_user"]:
                    item.setText(f"{display_text} (User)")
                else:
                    item.setText(display_text)
                    # Make system presets visually distinct and non-selectable for modification
                    item.setForeground(QtGui.QColor("gray"))
                    flags = item.flags()
                    flags &= ~QtCore.Qt.ItemIsSelectable
                    item.setFlags(flags)

                # Store the stable filename as data in the item
                item.setData(QtCore.Qt.UserRole, filename)
                self.preset_list.addItem(item)

        def _on_selection_changed(self):
            """Enables/disables buttons based on the current selection."""
            selected_items = self.preset_list.selectedItems()
            is_user_preset_selected = False

            if selected_items:
                filename = selected_items[0].data(QtCore.Qt.UserRole)
                if self.presets[filename]["is_user"]:
                    is_user_preset_selected = True

            self.btn_rename.setEnabled(is_user_preset_selected)
            self.btn_delete.setEnabled(is_user_preset_selected)
            self.btn_edit_source.setEnabled(is_user_preset_selected)

            # --- Add Tooltips for Disabled State (Refinement #2) ---
            tooltip = translate("Arch", "This action is only available for user-created presets.")
            self.btn_rename.setToolTip("" if is_user_preset_selected else tooltip)
            self.btn_delete.setToolTip("" if is_user_preset_selected else tooltip)
            self.btn_edit_source.setToolTip("" if is_user_preset_selected else tooltip)

        def _on_rename(self):
            """Handles the rename action."""
            item = self.preset_list.selectedItems()[0]
            filename = item.data(QtCore.Qt.UserRole)
            current_name = self.presets[filename]["name"]

            # --- Live Name Collision Check (Refinement #2) ---
            existing_names = {p["name"] for f, p in self.presets.items() if f != filename}

            new_name, ok = QtWidgets.QInputDialog.getText(
                self,
                translate("Arch", "Rename Preset"),
                translate("Arch", "New name:"),
                text=current_name,
            )
            if ok and new_name and new_name != current_name:
                if new_name in existing_names:
                    QtWidgets.QMessageBox.warning(
                        self,
                        translate("Arch", "Name Conflict"),
                        translate(
                            "Arch",
                            "A preset with this name already exists. Please choose a different name.",
                        ),
                    )
                    return

                _rename_preset(self.mode, filename, new_name)
                self._populate_list()  # Refresh the list

        def _on_delete(self):
            """Handles the delete action."""
            item = self.preset_list.selectedItems()[0]
            filename = item.data(QtCore.Qt.UserRole)
            name = self.presets[filename]["name"]

            reply = QtWidgets.QMessageBox.question(
                self,
                translate("Arch", "Delete Preset"),
                translate(
                    "Arch", f"Are you sure you want to permanently delete the preset '{name}'?"
                ),
                QtWidgets.QMessageBox.Yes | QtWidgets.QMessageBox.No,
                QtWidgets.QMessageBox.No,
            )

            if reply == QtWidgets.QMessageBox.Yes:
                _delete_preset(self.mode, filename)
                self._populate_list()

        def _on_edit_source(self):
            """Opens the preset's JSON file in an external editor."""
            item = self.preset_list.selectedItems()[0]
            filename = item.data(QtCore.Qt.UserRole)
            _, user_path = _get_preset_paths(self.mode)
            file_path = os.path.join(user_path, filename)

            if not os.path.exists(file_path):
                QtWidgets.QMessageBox.critical(
                    self,
                    translate("Arch", "File Not Found"),
                    translate("Arch", f"Could not find the preset file at:\n{file_path}"),
                )
                return

            # --- Use QDesktopServices for robust, cross-platform opening (Refinement #3) ---
            url = QUrl.fromLocalFile(file_path)
            if not QDesktopServices.openUrl(url):
                QtWidgets.QMessageBox.warning(
                    self,
                    translate("Arch", "Could Not Open File"),
                    translate(
                        "Arch",
                        "FreeCAD could not open the file. Please check if you have a default text editor configured in your operating system.",
                    ),
                )

    class NoScrollHijackComboBox(QtWidgets.QComboBox):
        """
        A custom QComboBox that only processes wheel events when its popup view is visible.
        This prevents it from "hijacking" the scroll wheel from a parent QScrollArea.
        """

        def wheelEvent(self, event):
            if self.view().isVisible():
                # If the widget has focus, perform the default scrolling action.
                super().wheelEvent(event)
            else:
                # If the popup is not visible, ignore the event. This allows
                # the event to propagate to the parent widget (the scroll area).
                event.ignore()

    class SqlSyntaxHighlighter(QtGui.QSyntaxHighlighter):
        """
        Custom QSyntaxHighlighter for SQL syntax.
        """

        def __init__(self, parent_text_document):
            super().__init__(parent_text_document)

            # --- Define Formatting Rules ---
            keyword_format = QtGui.QTextCharFormat()
            keyword_format.setForeground(QtGui.QColor("#0070C0"))  # Dark Blue
            keyword_format.setFontWeight(QtGui.QFont.Bold)

            function_format = QtGui.QTextCharFormat()
            function_format.setForeground(QtGui.QColor("#800080"))  # Purple
            function_format.setFontItalic(True)

            string_format = QtGui.QTextCharFormat()
            string_format.setForeground(QtGui.QColor("#A31515"))  # Dark Red

            comment_format = QtGui.QTextCharFormat()
            comment_format.setForeground(QtGui.QColor("#008000"))  # Green
            comment_format.setFontItalic(True)

            # --- Build Rules List ---
            self.highlighting_rules = []

            if hasattr(QtCore.QRegularExpression, "PatternOption"):
                # This is the PySide6/Qt6 structure
                CaseInsensitiveOption = (
                    QtCore.QRegularExpression.PatternOption.CaseInsensitiveOption
                )
            else:
                # This is the PySide2/Qt5 structure
                CaseInsensitiveOption = QtCore.QRegularExpression.CaseInsensitiveOption

            # Keywords (case-insensitive regex)
            # Get the list of keywords from the SQL engine.
            for word in ArchSql.getSqlKeywords():
                pattern = QtCore.QRegularExpression(r"\b" + word + r"\b", CaseInsensitiveOption)
                rule = {"pattern": pattern, "format": keyword_format}
                self.highlighting_rules.append(rule)

            # Aggregate Functions (case-insensitive regex)
            functions = ["COUNT", "SUM", "MIN", "MAX"]
            for word in functions:
                pattern = QtCore.QRegularExpression(r"\b" + word + r"\b", CaseInsensitiveOption)
                rule = {"pattern": pattern, "format": function_format}
                self.highlighting_rules.append(rule)

            # String Literals (single quotes)
            # This regex captures everything between single quotes, allowing for escaped quotes
            string_pattern = QtCore.QRegularExpression(r"'[^'\\]*(\\.[^'\\]*)*'")
            self.highlighting_rules.append({"pattern": string_pattern, "format": string_format})
            # Also support double-quoted string literals (some SQL dialects use double quotes)
            double_string_pattern = QtCore.QRegularExpression(r'"[^"\\]*(\\.[^"\\]*)*"')
            self.highlighting_rules.append(
                {"pattern": double_string_pattern, "format": string_format}
            )

            # Single-line comments (starting with -- or #)
            comment_single_line_pattern = QtCore.QRegularExpression(r"--[^\n]*|\#[^\n]*")
            self.highlighting_rules.append(
                {"pattern": comment_single_line_pattern, "format": comment_format}
            )

            # Multi-line comments (/* ... */) - requires special handling in highlightBlock
            self.multi_line_comment_start_pattern = QtCore.QRegularExpression(r"/\*")
            self.multi_line_comment_end_pattern = QtCore.QRegularExpression(r"\*/")
            self.multi_line_comment_format = comment_format

        def highlightBlock(self, text):
            """
            Applies highlighting rules to the given text block.
            This method is called automatically by Qt for each visible text block.
            """
            # --- Part 1: Handle single-line rules ---
            # Iterate over all the rules defined in the constructor
            for rule in self.highlighting_rules:
                pattern = rule["pattern"]
                format = rule["format"]

                # Get an iterator for all matches
                iterator = pattern.globalMatch(text)
                while iterator.hasNext():
                    match = iterator.next()
                    # Apply the format to the matched text
                    self.setFormat(match.capturedStart(), match.capturedLength(), format)

            # --- Part 2: Handle multi-line comments (which span blocks) ---
            self.setCurrentBlockState(0)

            startIndex = 0
            # Check if the previous block was an unclosed multi-line comment
            if self.previousBlockState() != 1:
                # It wasn't, so find the start of a new comment in the current line
                match = self.multi_line_comment_start_pattern.match(text)
                startIndex = match.capturedStart() if match.hasMatch() else -1
            else:
                # The previous block was an unclosed comment, so this block starts inside a comment
                startIndex = 0

            while startIndex >= 0:
                # Find the end of the comment
                end_match = self.multi_line_comment_end_pattern.match(text, startIndex)
                commentLength = 0

                if not end_match.hasMatch():
                    # The comment doesn't end in this line, so it spans the rest of the block
                    self.setCurrentBlockState(1)
                    commentLength = len(text) - startIndex
                else:
                    # The comment ends in this line
                    commentLength = end_match.capturedEnd() - startIndex

                self.setFormat(startIndex, commentLength, self.multi_line_comment_format)

                # Look for the next multi-line comment in the same line
                next_start_index = startIndex + commentLength
                next_match = self.multi_line_comment_start_pattern.match(text, next_start_index)
                startIndex = next_match.capturedStart() if next_match.hasMatch() else -1

    class CheatsheetDialog(QtWidgets.QDialog):
        """A simple dialog to display the HTML cheatsheet."""

        def __init__(self, api_data, parent=None):
            super().__init__(parent)
            self.setWindowTitle(translate("Arch", "BIM SQL Cheatsheet"))
            self.setMinimumSize(800, 600)
            layout = QtWidgets.QVBoxLayout(self)
            html = self._format_as_html(api_data)
            text_edit = QtWidgets.QTextEdit()
            text_edit.setReadOnly(True)
            text_edit.setHtml(html)
            layout.addWidget(text_edit)
            button_box = QtWidgets.QDialogButtonBox(QtWidgets.QDialogButtonBox.Ok)
            button_box.accepted.connect(self.accept)
            layout.addWidget(button_box)
            self.setLayout(layout)

        def _format_as_html(self, api_data: dict) -> str:
            """
            Takes the structured data from the API and builds the final HTML string.
            All presentation logic and translatable strings are contained here.
            """
            html = f"<h1>{translate('Arch', 'BIM SQL Cheatsheet')}</h1>"
            html += f"<h2>{translate('Arch', 'Clauses')}</h2>"
            html += f"<code>{', '.join(sorted(api_data.get('clauses', [])))}</code>"
            html += f"<h2>{translate('Arch', 'Key Functions')}</h2>"
            # Sort categories for a consistent display order
            for category_name in sorted(api_data.get("functions", {}).keys()):
                functions = api_data["functions"][category_name]
                html += f"<b>{category_name}:</b><ul>"
                # Sort functions within a category alphabetically
                for func_data in sorted(functions, key=lambda x: x["name"]):
                    # Add a bottom margin to the list item for clear visual separation.
                    html += f"<li style='margin-bottom: 10px;'><code>{func_data['signature']}</code><br>{func_data['description']}"  # Add the example snippet if it exists
                    if func_data.get("snippet"):
                        snippet_html = func_data["snippet"].replace("\n", "<br>")
                        # No <br> before the snippet. Added styling to make the snippet stand out.
                        html += f"<pre style='margin-top: 4px; padding: 5px; background-color: #f0f0f0; border: 1px solid #ccc;'><code>{snippet_html}</code></pre></li>"
                    else:
                        html += "</li>"
                html += "</ul>"
            return html

else:
    # In headless mode, we don't need the GUI classes.
    pass
