# Copyright 2026 Espressif Systems (Shanghai) Co., Ltd.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#     http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

import pyqtgraph as pg
from PySide6.QtCore import Qt, QTimer, Signal, Slot  # Added Qt import here
from PySide6.QtWidgets import (QCheckBox, QComboBox, QDialog, QFileDialog,
                               QHBoxLayout, QHeaderView, QLabel, QMainWindow,
                               QMenu, QMessageBox, QPushButton, QSplitter,
                               QTableWidget, QTableWidgetItem, QTabWidget,
                               QTextEdit, QToolBar, QToolButton, QVBoxLayout,
                               QWidget)

from esp_viewer.core.drivers import SerialDriver
from esp_viewer.ui.dynamic_panel import DynamicControlPanel
from esp_viewer.ui.pyside_adapter import PySideAdapter


class GenericWindow(QMainWindow):
    """
    The main UI window for ESP Viewer.
    It dynamically generates control widgets and visualization plots based on a JSON profile.
    It communicates with the core engine via the PySideAdapter.
    """

    def __init__(self, profile, adapter: PySideAdapter):
        super().__init__()
        self.profile = profile
        self.adapter = adapter
        app_title = profile.get("meta", {}).get("name", "ESP Viewer")
        self.setWindowTitle(app_title)
        self.resize(1200, 800)

        # Profile Update State
        self.pending_updates = {}  # pid -> new_value
        self.custom_io_log_path = None

        self._init_ui()
        self._connect_adapter_signals()

        # Initial Port Scan
        self._scan_ports()

    def _connect_adapter_signals(self):
        """Bind adapter signals to UI slot methods."""
        sig = self.adapter.signals
        sig.sig_status_changed.connect(self._handle_status_change)
        sig.sig_log_message.connect(self._append_log)
        sig.sig_io_data.connect(self._append_io)
        sig.sig_param_update.connect(self._handle_param_update)
        sig.sig_profile_update.connect(self._handle_profile_def_update)

        # Connect text stream and log status if those methods exist (create dummy if not)
        if hasattr(self, "_handle_text_stream"):
            sig.sig_text_stream.connect(self._handle_text_stream)
        if hasattr(self, "_handle_io_log_status"):
            sig.sig_io_log_status.connect(self._handle_io_log_status)

        sig.sig_stream_data_batch.connect(self._batch_update_plots)

    def closeEvent(self, event):
        # Engine is stopped by main.py, nothing to do here
        event.accept()

    def _init_ui(self):
        central = QWidget()
        self.setCentralWidget(central)
        layout = QVBoxLayout(central)

        # Toolkit
        self._init_toolbar(layout)

        # Main Splitter (Left: Plots/Logs, Right: Config)
        main_splitter = QSplitter(Qt.Horizontal)

        # Left Side: Plot + Log
        left_splitter = QSplitter(Qt.Vertical)

        # 1. Plot Area (Now a Splitter for individual resizing)
        self.plot_area = QSplitter(Qt.Vertical)
        self.plots = []  # List of PlotItems
        self._setup_plots()
        left_splitter.addWidget(self.plot_area)

        # 3. Log Area (Tabs: Events | ... | System Log | Data IO)
        self.tabs_log = QTabWidget()
        self._init_monitors()

        left_splitter.addWidget(self.tabs_log)

        # Profile-driven Layout Ratios
        gui_cfg = self.profile.get("gui", {}).get("layout", {})
        plot_ratio = gui_cfg.get("plot_ratio", 3)
        log_ratio = gui_cfg.get("log_ratio", 1)
        config_ratio = gui_cfg.get("config_ratio", 1)

        # Set sizes for left splitter (Vertical): Plot vs Log
        left_splitter.setStretchFactor(0, plot_ratio)
        left_splitter.setStretchFactor(1, log_ratio)

        main_splitter.addWidget(left_splitter)

        # 2. Config Area (Right)
        self.config_panel = DynamicControlPanel(self.profile.get("parameters", []))
        self.config_panel.param_changed.connect(self._send_set_param)
        self.config_panel.setMinimumWidth(300)
        main_splitter.addWidget(self.config_panel)

        # Set sizes for main splitter (Horizontal): Left Side vs Config Panel
        main_splitter.setStretchFactor(0, plot_ratio + log_ratio)
        main_splitter.setStretchFactor(1, config_ratio)

        layout.addWidget(main_splitter)

    def _init_toolbar(self, layout):
        toolbar = QHBoxLayout()

        toolbar.addWidget(QLabel("Port:"))
        self.combo_port = QComboBox()
        toolbar.addWidget(self.combo_port)

        btn_scan = QPushButton("Scan")
        btn_scan.clicked.connect(self._scan_ports)
        toolbar.addWidget(btn_scan)

        toolbar.addWidget(QLabel("Baud:"))
        self.combo_baud = QComboBox()
        self.combo_baud.addItems(["9600", "115200", "921600", "1000000", "2000000"])
        # Set default from profile
        default_baud = str(
            self.profile.get("connection", {}).get("default_baud", "115200")
        )
        self.combo_baud.setCurrentText(default_baud)
        toolbar.addWidget(self.combo_baud)

        self.btn_connect = QPushButton("Connect")
        self.btn_connect.clicked.connect(self._toggle_connection)
        toolbar.addWidget(self.btn_connect)

        self.btn_stream = QPushButton("Start Stream")
        self.btn_stream.setCheckable(True)
        self.btn_stream.clicked.connect(self._toggle_stream)
        self.btn_stream.setEnabled(False)
        toolbar.addWidget(self.btn_stream)

        btn_clear = QPushButton("Clear")
        btn_clear.clicked.connect(self._clear_plots)
        toolbar.addWidget(btn_clear)

        toolbar.addStretch()
        layout.addLayout(toolbar)

        # Tools Menu
        # Adding tools to the toolbar for quick access
        btn_tools = QToolButton()
        btn_tools.setText("Tools")
        btn_tools.setPopupMode(QToolButton.InstantPopup)

        self.tools_menu = QMenu()
        act_fetch = self.tools_menu.addAction("Fetch Profile Definitions")
        act_fetch.triggered.connect(self._fetch_profile)

        btn_tools.setMenu(self.tools_menu)

        toolbar.addWidget(btn_tools)

    def _send_set_param(self, pid, val):
        self.adapter.set_param(pid, val)

    def _append_io(self, payload):
        data = payload.get("data", "")
        # Simple text append for now
        self.io_console.append(str(data))

    def _update_marker(self, marker_data):
        # Generic implementation for markers
        # marker_data expected keys: 'ts', 'label', 'color'
        ts = marker_data.get("ts")
        if ts is None:
            return

        label = marker_data.get("label", "Mark")
        color = marker_data.get("color", "y")

        for i, p in enumerate(self.plots):
            v_line = pg.InfiniteLine(pos=ts, angle=90, movable=False, pen=color)
            label_item = None
            if i == 0:  # Only draw text label on the top plot (performance)
                label_item = pg.TextItem(text=label, color=color, anchor=(0, 0))
                label_item.setPos(ts, 0)

            p.addItem(v_line)
            if label_item:
                p.addItem(label_item)
            self.active_markers.append(
                {"line": v_line, "label": label_item, "ts": ts, "plot": p}
            )

        # Also record in Table Monitor (Unified Event Flow)
        self._add_event_to_table(marker_data)

    def _clean_markers(self, min_ts):
        to_remove = []
        for marker in self.active_markers:
            if marker["ts"] < min_ts:
                marker["plot"].removeItem(marker["line"])
                if "label" in marker:
                    marker["plot"].removeItem(marker["label"])
                to_remove.append(marker)
        for m in to_remove:
            self.active_markers.remove(m)

    def _init_monitors(self):
        # 1. Custom Monitors from Profile
        self.text_streams = {}  # sid -> text_widget

        # If no monitors defined, use defaults? Or just explicit ones?
        # User requirement: "System Log and Data IO ... default presented, not controlled by monitor"

        monitors = self.profile.get("monitors", [])

        # Pre-create System Log and Data IO widgets to be added at end (or by order?)
        # Let's add Custom Monitors first, then System Log, then Data IO.

        for mon in monitors:
            m_type = mon.get("type")
            title = mon.get("title", "Monitor")

            if m_type == "table":
                # Similar to old "Events" tab
                # We need to know which streams feed this.
                # Assuming this is THE event table.
                # Ideally we support multiple? For now let's map it to self.event_table
                widget = QTableWidget()
                widget.setColumnCount(4)
                widget.setHorizontalHeaderLabels(["Time", "Ch", "Event", "Value"])
                widget.horizontalHeader().setSectionResizeMode(QHeaderView.Stretch)
                self.event_table = widget  # Bind to existing logic
                self.tabs_log.addTab(widget, title)

            elif m_type == "text":
                sid = mon.get("stream_id")
                widget = QTextEdit()
                widget.setReadOnly(True)
                self.tabs_log.addTab(widget, title)
                if sid:
                    self.text_streams[sid] = widget

        # 2. System Log (Always Present)
        self.log_console = QTextEdit()
        self.log_console.setReadOnly(True)
        self.tabs_log.addTab(self.log_console, "System Log")

        # 3. Data IO (Always Present)
        io_widget = QWidget()
        io_layout = QVBoxLayout(io_widget)
        io_layout.setContentsMargins(0, 0, 0, 0)

        self.io_console = QTextEdit()
        self.io_console.setReadOnly(True)
        io_layout.addWidget(self.io_console)

        # TX Input Area
        tx_layout = QHBoxLayout()
        from PySide6.QtWidgets import QCheckBox, QLineEdit

        self.tx_input = QLineEdit()
        self.tx_input.setPlaceholderText("Send command...")
        self.tx_input.returnPressed.connect(self._send_io_tx)

        self.chk_hex = QCheckBox("Hex")
        btn_send = QPushButton("Send")
        btn_send.clicked.connect(self._send_io_tx)

        tx_layout.addWidget(QLabel("TX:"))
        tx_layout.addWidget(self.tx_input)
        tx_layout.addWidget(self.chk_hex)
        tx_layout.addWidget(btn_send)

        io_layout.addLayout(tx_layout)

        # Recording Control Area
        rec_layout = QHBoxLayout()
        self.chk_record_io = QCheckBox("Record to file")
        self.chk_record_io.toggled.connect(self._toggle_io_recording)

        btn_browse_io = QPushButton("...")
        btn_browse_io.setFixedWidth(30)
        btn_browse_io.clicked.connect(self._select_io_log_path)

        self.lbl_io_log_path = QLabel("<i>Not recording</i>")
        self.lbl_io_log_path.setStyleSheet("color: gray;")

        rec_layout.addWidget(self.chk_record_io)
        rec_layout.addWidget(btn_browse_io)
        rec_layout.addWidget(self.lbl_io_log_path)
        rec_layout.addStretch()

        io_layout.addLayout(rec_layout)

        self.tabs_log.addTab(io_widget, "Data IO")

    def _send_io_tx(self):
        text = self.tx_input.text()
        if not text:
            return

        if self.chk_hex.isChecked():
            # Convert Hex String "A1 B2" to bytes? Backend expects string mostly or bytes?
            # Backend write accepts string/bytes. SerialDriver writes bytes.
            # Let's assume hex string -> bytes
            try:
                data = bytes.fromhex(text.replace(" ", ""))
                # How to pass bytes to backend?
                # Packet payload is JSON serializable. Bytes are not.
                # Protocol change needed or just string?
                # For this tool, mostly string cmds. If hex needed, maybe just emit 'write_raw'
                # Let's wrap in a special command or just set param?
                # We need a new command: WRITE_DATA
                pass
            except ValueError:
                self.io_console.append("<font color='red'>Invalid Hex</font>")
                return
        else:
            # String
            # Append EOL? Profile has EOL.
            # Send as is? Usually user expects typing "cmd" + Enter sends "cmd\n"
            data = text

        # We need a generic WRITE command
        # Let's use SET_PARAM hack or add WRITE_DATA command.
        # Adding WRITE_DATA command is clean.
        self.adapter.write_data(text, self.chk_hex.isChecked())

        # self.tx_input.clear() # Keep it for repeat? User preference.

    def _fetch_profile(self):
        """
        Requests the full profile definition from the connected device.
        This is used to dynamically synchronize UI widgets with device capabilities.
        """
        if self.btn_connect.text() == "Connect":
            QMessageBox.warning(self, "Warning", "Please connect to device first.")
            return

        reply = QMessageBox.question(
            self,
            "Fetch Profile",
            "This will send commands to fetch parameter definitions from the device.\nContinue?",
            QMessageBox.Yes | QMessageBox.No,
        )
        if reply == QMessageBox.Yes:
            self.adapter.fetch_profile()

    def _scan_ports(self):
        self.combo_port.clear()
        self.combo_port.addItem("Mock Device")
        ports = SerialDriver.list_ports()
        self.combo_port.addItems(ports)
        self.log_console.append(f"<i>Scanned {len(ports)} ports.</i>")

    def _toggle_connection(self):
        if self.btn_connect.text() == "Connect":
            # Connect Logic
            port = self.combo_port.currentText()
            baud = int(self.combo_baud.currentText())

            if port == "Mock Device":
                # Dynamic Mock Loading
                mock_class_path = self.profile.get("simulation", {}).get(
                    "mock_class", "esp_viewer.core.mock_device.MockDevice"
                )
                try:
                    import importlib

                    module_path, class_name = mock_class_path.rsplit(".", 1)
                    module = importlib.import_module(module_path)
                    mock_class = getattr(module, class_name)
                    driver = mock_class("MOCK")
                    # Inject profile for generic generators
                    if hasattr(driver, "set_profile"):
                        driver.set_profile(self.profile)
                    self.log_console.append(
                        f"<i>Using simulation: {mock_class_path}</i>"
                    )
                except Exception as e:
                    self.log_console.append(
                        f"<font color='red'>Failed to load mock {mock_class_path}: {e}. Falling back to default.</font>"
                    )
                    from esp_viewer.core.mock_device import MockDevice

                    driver = MockDevice("MOCK")
            else:
                driver = SerialDriver(port, baud)

            self.adapter.set_device_interface(driver)
            self.adapter.connect_device()

            # Update UI state pending (wait for event to confirm)
            self.btn_connect.setText("Connecting...")
            self.btn_connect.setEnabled(False)
            self.combo_port.setEnabled(False)
            self.combo_baud.setEnabled(False)
        else:
            # Disconnect Logic
            self.adapter.disconnect_device()
            self.btn_connect.setText("Disconnecting...")
            self.btn_connect.setEnabled(False)

    def _toggle_io_recording(self, checked):
        self.adapter.set_io_logging(checked, self.custom_io_log_path)

    def _select_io_log_path(self):
        import os
        from datetime import datetime

        default_name = datetime.now().strftime("%Y%m%d_%H%M%S.log")
        path, _ = QFileDialog.getSaveFileName(
            self, "Select IO Log File", default_name, "Log Files (*.log);;All Files (*)"
        )
        if path:
            self.custom_io_log_path = path
            if not self.chk_record_io.isChecked():
                self.lbl_io_log_path.setText(f"Pending: {os.path.basename(path)}")
                self.lbl_io_log_path.setStyleSheet("color: blue;")
            else:
                # If already recording, restart with new path
                self.adapter.set_io_logging(True, path)

    def _toggle_stream(self):
        if self.btn_stream.isChecked():
            # Auto-clear on new session
            self._clear_plots()
            self.adapter.start_stream()
            self.btn_stream.setText("Stop Stream")
        else:
            self.adapter.stop_stream()
            self.btn_stream.setText("Start Stream")

    def _handle_status_change(self, connected):
        self.btn_connect.setEnabled(True)
        if connected:
            self.btn_connect.setText("Disconnect")
            self.btn_stream.setEnabled(True)
            self.combo_port.setEnabled(False)
            self.combo_baud.setEnabled(False)
            self.log_console.append("<b>[System] Connected.</b>")

            # Fetch Parameters
            for pid in self.profile.get("parameters", []):
                self.adapter.get_param(pid["id"])

            # Auto start stream if configured? Let's leave manual for now or auto click
            self.btn_stream.click()
        else:
            self.btn_connect.setText("Connect")
            self.btn_stream.setEnabled(False)
            self.btn_stream.setChecked(False)
            self.btn_stream.setText("Start Stream")
            self.combo_port.setEnabled(True)
            self.combo_baud.setEnabled(True)
            self.log_console.append("<b>[System] Disconnected.</b>")

    def _on_channel_selected(self, ch_id):
        pass  # Override in subclass

    # The event routing logic is now handled by the adapter.
    # self._process_events is entirely removed.

    def _batch_update_plots(self, batch_payloads):
        # 1. Update Buffers with all data in batch
        affected_plots = set()

        for payload in batch_payloads:
            # Check for Marker
            if "marker" in payload:
                self._update_marker(payload["marker"])
                continue

            values = payload.get("values", {})
            stream_id = values.get("_stream_id")
            # Support 'channel' or 'ch'
            val_ch = values.get("channel")
            if val_ch is None:
                val_ch = values.get("ch")
            chan_idx = int(val_ch if val_ch is not None else 0)

            curr_ts = int(values.get("ts") or values.get("time") or 0)

            # Identify Targets for plotting
            target_indices = self._get_target_plots(stream_id, chan_idx)

            for idx in target_indices:
                affected_plots.add(idx)
                row_buffers = self.data_buffers[idx]
                row_curves = self.plot_curves[idx]

                # X Buffer
                if "_x" not in row_buffers:
                    row_buffers["_x"] = []
                row_buffers["_x"].append(curr_ts)

                # Y Buffers
                for key, val in values.items():
                    if key in row_curves:
                        if key not in row_buffers:
                            row_buffers[key] = []
                        row_buffers[key].append(val)

        # 2. Prune Buffers (Scanning once per affected plot)
        for idx in affected_plots:
            row_buffers = self.data_buffers[idx]
            max_size = self.plot_window_sizes[idx]

            # Prune based on length of _x
            if "_x" in row_buffers:
                overhang = len(row_buffers["_x"]) - max_size
                if overhang > 0:
                    row_buffers["_x"] = row_buffers["_x"][overhang:]
                    for k in row_buffers:
                        if k != "_x" and isinstance(row_buffers[k], list):
                            row_buffers[k] = row_buffers[k][overhang:]

        # 3. Redraw (SetData)
        for idx in affected_plots:
            row_buffers = self.data_buffers[idx]
            row_curves = self.plot_curves[idx]
            if "_x" in row_buffers:
                x_data = row_buffers["_x"]
                for key, curve in row_curves.items():
                    if key in row_buffers:
                        y_data = row_buffers[key]
                        if len(y_data) == len(x_data):
                            curve.setData(x=x_data, y=y_data)

                # 3.1 Synchronize X-Range (Prevents marker jitter/jumping)
                if idx == 0 and len(x_data) > 1:
                    self.plots[0].setXRange(x_data[0], x_data[-1], padding=0)

        # 4. Clean Markers (Once per batch across all plots)
        if affected_plots:
            # Find the oldest timestamp currently visible in any active plot
            global_min_ts = None
            for idx in affected_plots:
                rb = self.data_buffers[idx]
                if "_x" in rb and rb["_x"]:
                    if global_min_ts is None or rb["_x"][0] < global_min_ts:
                        global_min_ts = rb["_x"][0]

            if global_min_ts is not None:
                self._clean_markers(global_min_ts)

    def _get_target_plots(self, stream_id, chan_idx):
        target_indices = []
        for i, p_cfg in enumerate(self.profile.get("plots", [])):

            if p_cfg.get("dynamic"):
                # Only match if this is the active channel
                if not hasattr(self, "active_channel") or self.active_channel is None:
                    continue
                if chan_idx != self.active_channel:
                    continue

            match = False
            plot_sid = p_cfg.get("stream_id")

            # 1. Direct Stream Match
            if plot_sid and plot_sid == stream_id:
                match = True

            # 2. Match within y_items
            if not match:
                for item in p_cfg.get("y_items", []):
                    if isinstance(item, dict) and item.get("stream_id") == stream_id:
                        match = True
                        break
                    elif isinstance(item, str) and plot_sid == stream_id:
                        # This would be covered by 1, but for safety
                        match = True
                        break

            # 3. Fallback to Channel Index ONLY if no explicit stream definition exists for this plot
            # AND NOT DYNAMIC (Dynamic already checked above)
            if not match and not plot_sid and not p_cfg.get("dynamic"):
                # Check if any y_item has a stream_id. If none do, we allow fallback
                has_explicit_y_sid = any(
                    isinstance(item, dict) and "stream_id" in item
                    for item in p_cfg.get("y_items", [])
                )
                if not has_explicit_y_sid and chan_idx == i:
                    match = True

            if match:
                target_indices.append(i)
        return target_indices

    def _setup_plots(self):
        # Auto-inject dynamic plot if needed
        # If no plots defined, and we have selected_channels (Touch Tool Mode), add one
        has_plots = len(self.profile.get("plots", [])) > 0
        is_touch_tool = "selected_channels" in self.profile.get("meta", {})

        if not has_plots and is_touch_tool:
            self.profile["plots"] = [
                {
                    "title": "Channel Data",
                    "dynamic": True,
                    "y_items": ["val", "value", "value1"],  # Try common keys
                    "stream_id": "touch_data",  # Default stream name in template
                }
            ]

        # Read profile 'plots'
        plot_configs = self.profile.get("plots", [])

        self.plot_curves = []
        self.plots = []
        self.plot_window_sizes = []  # Store window size per plot

        for i, p_cfg in enumerate(plot_configs):
            title = p_cfg.get("title", f"Channel {i}")
            # Window Size (Default 500)
            w_size = p_cfg.get("window_size", 500)
            self.plot_window_sizes.append(w_size)

            # Create individual PlotWidget for each plot config to allow resizing via Splitter
            pw = pg.PlotWidget(title=title)
            p = pw.getPlotItem()

            # Disable X auto-range to prevent markers from "pushing" the view
            p.enableAutoRange(x=False, y=True)

            # Link X-axes for synchronization
            if i == 0:
                self.first_plot = p
            else:
                p.setXLink(self.first_plot)

            self.plots.append(p)
            self.plot_area.addWidget(pw)
            p.addLegend()

            # Init curves for this plot
            y_items = p_cfg.get("y_items", [])
            curves = {}
            colors = [
                (255, 0, 0),
                (0, 255, 0),
                (0, 0, 255),
                (255, 255, 0),
                (0, 255, 255),
            ]

            for j, item in enumerate(y_items):
                # Flexible item definition
                if isinstance(item, dict):
                    key = item.get("key")
                    color = item.get("color", colors[j % len(colors)])
                    # Store stream_id in curve user data or map?
                    # (Comments removed for brevity)
                    pass
                else:
                    key = item
                    color = colors[j % len(colors)]

                if key:
                    curves[key] = p.plot(pen=color, name=key)

            self.plot_curves.append(curves)

        # Data buffers for plotting
        # Structure: self.data_buffers[plot_index][curve_name] = [list]
        self.data_buffers = []
        self.active_markers = []  # List of {'line': item, 'ts': ts, 'plot': plot_item}
        for _ in plot_configs:
            self.data_buffers.append({})

    def _clear_plots(self):
        """Wipes all current data from plots, marker lists, and the event table."""
        # 1. Clear Plot Buffers
        for buf in self.data_buffers:
            buf.clear()

        # 2. Clear Curves in UI
        for curves in self.plot_curves:
            for c in curves.values():
                c.setData([], [])

        # 3. Clear Visual Markers
        for m in self.active_markers:
            try:
                m["plot"].removeItem(m["line"])
                if "label" in m:
                    m["plot"].removeItem(m["label"])
            except:
                pass
        self.active_markers.clear()

        # 4. Clear Event Table (Monitor)
        if hasattr(self, "event_table"):
            self.event_table.setRowCount(0)

    def _update_plot(self, payload):
        pass

    def _add_event_to_table(self, m_data):
        if not hasattr(self, "event_table"):
            return

        row = self.event_table.rowCount()
        self.event_table.insertRow(row)

        # Cols: Time, Ch, Event, Value
        ts = m_data.get("ts") or m_data.get("time", "")
        ch = m_data.get("channel") or m_data.get("ch", "0")
        label = m_data.get("label", m_data.get("_stream_id", "Unknown"))
        # Collect other relevant values for "Value" column
        val_parts = [
            f"{k}={v}"
            for k, v in m_data.items()
            if not k.startswith("_")
            and k not in ("ts", "time", "channel", "ch", "label")
        ]
        val_str = ", ".join(val_parts)

        self.event_table.setItem(row, 0, QTableWidgetItem(str(ts)))
        self.event_table.setItem(row, 1, QTableWidgetItem(str(ch)))
        self.event_table.setItem(row, 2, QTableWidgetItem(str(label)))
        self.event_table.setItem(row, 3, QTableWidgetItem(val_str))

        self.event_table.scrollToBottom()

    def _append_log(self, payload):
        lvl = payload.get("level", "INFO")
        msg = payload.get("msg", "")
        # Colorize
        color = "black"
        if lvl == "ERROR":
            color = "red"
        elif lvl == "WARN":
            color = "orange"

        self.log_console.append(f"<span style='color:{color}'>[{lvl}] {msg}</span>")

    def _handle_param_update(self, payload):
        pid = payload.get("id")
        val = payload.get("value")
        self.config_panel.update_param_value(pid, val)

    def _handle_profile_def_update(self, payload):
        pid = payload.get("id")
        val = payload.get("val")
        # raw = payload.get('raw_line')

        self.pending_updates[pid] = val

        # Debounce or accumulate?
        # For simplicity, we just log it and maybe show a "Apply Updates" button or
        # wait for a timeout?
        # Ideally backend sends "Done" event. But we don't have one.
        # Let's count updates vs expected? or just timer.

        # Force single update flow:
        # User clicks Fetch -> Backend sends cmds -> Backend emits updates -> UI collects
        # Ideally we know when it's done.
        # Let's add a simple Timer to check "quiet period" after fetch?

        if not hasattr(self, "_collect_timer"):
            self._collect_timer = QTimer()
            self._collect_timer.setSingleShot(True)
            self._collect_timer.timeout.connect(self._apply_profile_updates)

        self._collect_timer.start(500)  # Wait 500ms after last update

    def _apply_profile_updates(self):
        if not self.pending_updates:
            return

        count = len(self.pending_updates)
        reply = QMessageBox.question(
            self,
            "Profile Updates",
            f"Received {count} parameter definition updates.\nDo you want to apply them and reload?",
            QMessageBox.Yes | QMessageBox.No,
        )

        if reply == QMessageBox.Yes:
            # Create new profile content
            import copy

            new_profile = copy.deepcopy(self.profile)

            # Apply updates
            for p in new_profile["parameters"]:
                pid = p["id"]
                if pid in self.pending_updates:
                    # Heuristic: what are we updating?
                    # The value captured could be "options" json, or "min,max".
                    # Let's assume for this feature we update 'default' value or 'options'
                    # based on what the value looks like.
                    new_val = self.pending_updates[pid]

                    # Try detection
                    if new_val.startswith("{") and new_val.endswith(
                        "}"
                    ):  # JSON Options
                        try:
                            import json

                            opts = json.loads(new_val)
                            p["options"] = opts
                        except:
                            pass
                    else:
                        # Assume default value update
                        try:
                            if p.get("type") == "int":
                                p["default"] = int(new_val)
                            elif p.get("type") == "float":
                                p["default"] = float(new_val)
                            else:
                                p["default"] = new_val
                        except:
                            pass

            # Save
            import json
            import os

            base_path = self.profile.get("_filepath")
            if base_path:
                dir_name = os.path.dirname(base_path)
                file_name = os.path.basename(base_path)
                name, ext = os.path.splitext(file_name)
                new_path = os.path.join(dir_name, f"{name}_updated{ext}")

                with open(new_path, "w") as f:
                    json.dump(new_profile, f, indent=4)

                QMessageBox.information(
                    self,
                    "Success",
                    f"Profile saved to {new_path}.\nPlease restart to load it.",
                )

            self.pending_updates.clear()

    def _handle_text_stream(self, payload):
        sid = payload.get("id")
        text = payload.get("text")
        if sid in self.text_streams:
            self.text_streams[sid].append(str(text))

    def _handle_io_log_status(self, payload):
        enabled = payload.get("enabled", False)
        path = payload.get("path")

        self.chk_record_io.blockSignals(True)
        self.chk_record_io.setChecked(enabled)
        self.chk_record_io.blockSignals(False)

        if enabled and path:
            self.lbl_io_log_path.setText(f"Recording to: <b>{path}</b>")
            self.lbl_io_log_path.setStyleSheet("color: green;")
        else:
            self.lbl_io_log_path.setText("<i>Not recording</i>")
            self.lbl_io_log_path.setStyleSheet("color: gray;")
