import numpy as np
from PyQt5 import QtWidgets, QtCore, QtGui

try:
    from .database import SpectraDatabase
except Exception:
    SpectraDatabase = None


class Beam:
    """Simple spectral beam: wavelength grid + named components."""

    def __init__(self, wavelengths):
        self.wavelengths = np.asarray(wavelengths, dtype=float)
        self.components = {}  # name -> np.ndarray (same shape as wavelengths)

    def copy(self):
        new_beam = Beam(self.wavelengths.copy())
        for name, spec in self.components.items():
            new_beam.components[name] = spec.copy()
        return new_beam

    def add_component(self, name, spectrum):
        spectrum = np.asarray(spectrum, dtype=float)
        if spectrum.shape != self.wavelengths.shape:
            raise ValueError("Spectrum length does not match wavelength grid")
        if name in self.components:
            self.components[name] = self.components[name] + spectrum
        else:
            self.components[name] = spectrum

    def apply_filter(self, transmission):
        transmission = np.asarray(transmission, dtype=float)
        if transmission.shape != self.wavelengths.shape:
            raise ValueError("Transmission length does not match wavelength grid")
        new_beam = Beam(self.wavelengths)
        for name, spec in self.components.items():
            new_beam.components[name] = spec * transmission
        return new_beam


class BaseNode(QtCore.QObject):
    """Base class for optical nodes in the path."""

    def __init__(self, kind: str, name: str):
        super().__init__()
        self.kind = kind
        self.name = name

    def display_name(self) -> str:
        return f"{self.kind}: {self.name}"

    def process(self, beam: Beam | None):
        """Return (output_beam, signals_dict).

        signals_dict is {channel_name: {component_name: value}} for Detector nodes,
        and empty for other nodes.
        """
        raise NotImplementedError


class LaserNodeModel(BaseNode):
    def __init__(self, name: str = "Laser"):
        super().__init__("Laser", name)
        self.wavelength = 488.0  # nm
        self.power = 100.0       # arbitrary units
        self.bandwidth = 2.0     # nm FWHM

    def excitation_component_name(self) -> str:
        return f"{self.name} ({int(self.wavelength)} nm)"

    def process(self, beam: Beam | None):
        # Define wavelength grid
        wavelengths = np.arange(300.0, 901.0, 1.0)
        out_beam = Beam(wavelengths)

        sigma = max(self.bandwidth, 0.1) / 2.355
        profile = np.exp(-(wavelengths - self.wavelength) ** 2 / (2.0 * sigma ** 2))
        if profile.sum() > 0:
            profile = profile / profile.sum() * self.power

        comp_name = self.excitation_component_name()
        out_beam.add_component(comp_name, profile)
        return out_beam, {}


class FilterNodeModel(BaseNode):
    def __init__(self, name: str = "Filter"):
        super().__init__("Filter", name)
        self.cutoff = 500.0              # nm
        self.filter_type = "LongPass"    # LongPass, ShortPass, BandPass
        self.bandwidth = 20.0            # nm for BandPass
        self.db_item_name = None
        self.db_tr_wl = None
        self.db_tr_vals = None

    def process(self, beam: Beam | None):
        if beam is None:
            return None, {}
        x = beam.wavelengths

        if self.db_tr_wl is not None and self.db_tr_vals is not None:
            tr_raw = np.interp(x, self.db_tr_wl, self.db_tr_vals, left=0.0, right=0.0)
            tr = np.clip(tr_raw, 0.0, 1.0)
        else:
            tr = np.ones_like(x, dtype=float)
            if self.filter_type == "LongPass":
                tr[x < self.cutoff] = 0.0
            elif self.filter_type == "ShortPass":
                tr[x > self.cutoff] = 0.0
            elif self.filter_type == "BandPass":
                half = self.bandwidth / 2.0
                lo = self.cutoff - half
                hi = self.cutoff + half
                tr[(x < lo) | (x > hi)] = 0.0

        return beam.apply_filter(tr), {}


class SampleNodeModel(BaseNode):
    def __init__(self, name: str = "Sample"):
        super().__init__("Sample", name)
        self.ext_coeff = 100000.0   # M^-1 cm^-1 (peak)
        self.qy = 0.8               # quantum yield (0-1)
        self.abs_max = 490.0        # nm
        self.em_max = 520.0         # nm
        self.conc_uM = 1.0          # uM
        self.path_len_cm = 1.0      # cm
        self.db_item_name = None
        self.db_abs_wl = None
        self.db_abs_vals = None
        self.db_em_wl = None
        self.db_em_vals = None

    def emission_component_name(self) -> str:
        return self.name

    def process(self, beam: Beam | None):
        if beam is None:
            return None, {}

        x = beam.wavelengths
        conc_M = self.conc_uM * 1e-6

        # Absorption spectrum (Gaussian shape, peak at 1)
        if self.db_abs_wl is not None and self.db_abs_vals is not None:
            abs_raw = np.interp(x, self.db_abs_wl, self.db_abs_vals, left=0.0, right=0.0)
            if abs_raw.max() > 0:
                abs_shape = abs_raw / abs_raw.max()
            else:
                abs_shape = np.zeros_like(x)
        else:
            sigma_abs = 30.0 / 2.355
            abs_shape = np.exp(-(x - self.abs_max) ** 2 / (2.0 * sigma_abs ** 2))
        # Extinction coefficient at each wavelength
        epsilon = abs_shape * self.ext_coeff

        # Beer-Lambert: A = epsilon * c * l
        A = epsilon * conc_M * self.path_len_cm
        transmission = 10.0 ** (-A)
        absorbed_fraction = 1.0 - transmission

        # Emission spectrum shape (Gaussian, normalized to area 1)
        if self.db_em_wl is not None and self.db_em_vals is not None:
            em_raw = np.interp(x, self.db_em_wl, self.db_em_vals, left=0.0, right=0.0)
            area = em_raw.sum()
            if area > 0:
                em_shape = em_raw / area
            else:
                em_shape = np.zeros_like(x)
        else:
            sigma_em = 40.0 / 2.355
            em_shape = np.exp(-(x - self.em_max) ** 2 / (2.0 * sigma_em ** 2))
            area = em_shape.sum()
            if area > 0:
                em_shape = em_shape / area

        out_beam = beam.apply_filter(transmission)

        # For each incoming component, generate emission
        for comp_name, intensity in beam.components.items():
            absorbed_power = float((intensity * absorbed_fraction).sum())
            if absorbed_power <= 0:
                continue
            emitted_power = absorbed_power * self.qy
            em_spec = em_shape * emitted_power

            em_name = self.emission_component_name()
            if em_name in out_beam.components:
                out_beam.components[em_name] = out_beam.components[em_name] + em_spec
            else:
                out_beam.components[em_name] = em_spec

        return out_beam, {}


class DetectorNodeModel(BaseNode):
    def __init__(self, name: str = "Detector"):
        super().__init__("Detector", name)
        self.min_wl = 300.0
        self.max_wl = 900.0

    @property
    def channel_name(self) -> str:
        return self.name

    def process(self, beam: Beam | None):
        if beam is None:
            return None, {}
        x = beam.wavelengths
        mask = (x >= self.min_wl) & (x <= self.max_wl)

        channel_signals = {}
        if mask.any():
            for comp_name, intensity in beam.components.items():
                # Integrate intensity over wavelength window
                total = float(np.trapz(intensity[mask], x[mask]))
                channel_signals[comp_name] = total

        signals = {}
        if channel_signals:
            signals[self.channel_name] = channel_signals

        # Detector does not alter the beam; it only measures it
        return beam, signals


class NodeGraphicsItem(QtWidgets.QGraphicsRectItem):
    """Simple rectangular representation of a node in the scene."""

    def __init__(self, model: BaseNode):
        super().__init__(-60, -30, 120, 60)
        self.model = model
        self.setFlags(
            QtWidgets.QGraphicsItem.ItemIsSelectable
        )

    def paint(self, painter: QtGui.QPainter, option, widget=None):
        rect = self.rect()

        # Fill color based on kind
        kind = self.model.kind
        if kind == "Laser":
            color = QtGui.QColor(200, 230, 255)
        elif kind == "Sample":
            color = QtGui.QColor(220, 255, 220)
        elif kind == "Filter":
            color = QtGui.QColor(255, 240, 200)
        elif kind == "Detector":
            color = QtGui.QColor(255, 220, 220)
        else:
            color = QtGui.QColor(230, 230, 230)

        if option.state & QtWidgets.QStyle.State_Selected:
            pen = QtGui.QPen(QtGui.QColor(0, 120, 215), 2)
        else:
            pen = QtGui.QPen(QtCore.Qt.black, 1)

        painter.setPen(pen)
        painter.setBrush(color)
        painter.drawRoundedRect(rect, 8, 8)

        # Draw node name
        painter.setPen(QtCore.Qt.black)
        text = self.model.display_name()
        painter.drawText(rect.adjusted(4, 4, -4, -4), QtCore.Qt.AlignCenter, text)


class CrosstalkCalculatorWidget(QtWidgets.QWidget):
    """Standalone optical path editor and crosstalk calculator."""

    def __init__(self, parent=None):
        super().__init__(parent)
        self.setWindowTitle("Optical Path Crosstalk Calculator")

        self.nodes: list[BaseNode] = []
        self.node_items: dict[BaseNode, NodeGraphicsItem] = {}
        self.connectors: list[QtWidgets.QGraphicsLineItem] = []
        self.selected_node: BaseNode | None = None
        self.db_fluorophores = {}
        self.db_filters = {}

        self._build_ui()
        self._init_database()
        self._create_default_path()

    # --- UI setup ---------------------------------------------------------
    def _build_ui(self):
        main_layout = QtWidgets.QHBoxLayout(self)

        # Left: node editor
        left_panel = QtWidgets.QWidget()
        left_layout = QtWidgets.QVBoxLayout(left_panel)

        # Toolbar for adding/removing/moving nodes
        toolbar1 = QtWidgets.QHBoxLayout()
        self.btn_add_laser = QtWidgets.QPushButton("Add Laser")
        self.btn_add_sample = QtWidgets.QPushButton("Add Sample")
        toolbar1.addWidget(self.btn_add_laser)
        toolbar1.addWidget(self.btn_add_sample)

        toolbar2 = QtWidgets.QHBoxLayout()
        self.btn_add_filter = QtWidgets.QPushButton("Add Filter")
        self.btn_add_detector = QtWidgets.QPushButton("Add Detector")
        toolbar2.addWidget(self.btn_add_filter)
        toolbar2.addWidget(self.btn_add_detector)

        toolbar3 = QtWidgets.QHBoxLayout()
        self.btn_remove = QtWidgets.QPushButton("Remove Node")
        self.btn_move_up = QtWidgets.QPushButton("Move Up")
        self.btn_move_down = QtWidgets.QPushButton("Move Down")
        toolbar3.addWidget(self.btn_remove)
        toolbar3.addWidget(self.btn_move_up)
        toolbar3.addWidget(self.btn_move_down)

        left_layout.addLayout(toolbar1)
        left_layout.addLayout(toolbar2)
        left_layout.addLayout(toolbar3)

        # Graphics scene + view
        self.scene = QtWidgets.QGraphicsScene(self)
        self.view = QtWidgets.QGraphicsView(self.scene)
        self.view.setRenderHint(QtGui.QPainter.Antialiasing)
        left_layout.addWidget(self.view, stretch=1)

        main_layout.addWidget(left_panel, stretch=1)

        # Right: properties + results
        right_panel = QtWidgets.QWidget()
        right_layout = QtWidgets.QVBoxLayout(right_panel)

        # Properties group
        prop_group = QtWidgets.QGroupBox("Node Properties")
        self.prop_form = QtWidgets.QFormLayout(prop_group)
        right_layout.addWidget(prop_group)

        # Calculate button
        self.btn_calculate = QtWidgets.QPushButton("Calculate Crosstalk Matrix")
        right_layout.addWidget(self.btn_calculate)

        # Results tables in tabs: Total, Excitation-only, Emission-only
        self.matrix_tabs = QtWidgets.QTabWidget()
        self.table_total = QtWidgets.QTableWidget()
        self.table_exc = QtWidgets.QTableWidget()
        self.table_em = QtWidgets.QTableWidget()
        for tbl in (self.table_total, self.table_exc, self.table_em):
            tbl.setSizeAdjustPolicy(QtWidgets.QAbstractScrollArea.AdjustToContents)
        self.matrix_tabs.addTab(self.table_total, "Total")
        self.matrix_tabs.addTab(self.table_exc, "Excitation")
        self.matrix_tabs.addTab(self.table_em, "Emission")
        right_layout.addWidget(self.matrix_tabs, stretch=1)

        main_layout.addWidget(right_panel, stretch=1)

        # Connections
        self.btn_add_laser.clicked.connect(self._on_add_laser)
        self.btn_add_sample.clicked.connect(self._on_add_sample)
        self.btn_add_filter.clicked.connect(self._on_add_filter)
        self.btn_add_detector.clicked.connect(self._on_add_detector)
        self.btn_remove.clicked.connect(self._on_remove_node)
        self.btn_move_up.clicked.connect(self._on_move_up)
        self.btn_move_down.clicked.connect(self._on_move_down)
        self.btn_calculate.clicked.connect(self._on_calculate)
        self.scene.selectionChanged.connect(self._on_scene_selection_changed)

    def _init_database(self):
        self.db_fluorophores = {}
        self.db_filters = {}
        if SpectraDatabase is None:
            return
        try:
            db = SpectraDatabase()
            with db:
                records = db.get_all_spectra()
            items = {}
            for item_id, item_name, spectrum_type, wavelengths, values in records:
                entry = items.setdefault(item_name, {"item_id": item_id, "spectra": {}})
                entry["spectra"][spectrum_type] = (np.asarray(wavelengths, dtype=float), np.asarray(values, dtype=float))
            for name, entry in items.items():
                spectra = entry.get("spectra", {})
                if "absorption" in spectra or "emission" in spectra:
                    self.db_fluorophores[name] = entry
                if "transmission" in spectra:
                    self.db_filters[name] = entry
        except Exception as exc:
            print("Error initializing SpectraDatabase in optical_path_editor:", exc)
            self.db_fluorophores = {}
            self.db_filters = {}

    def _create_default_path(self):
        # Simple default: Laser -> Sample -> Detector
        laser = LaserNodeModel("Laser 1")
        sample = SampleNodeModel("Dye 1")
        detector = DetectorNodeModel("Det 1")

        for node in (laser, sample, detector):
            self._add_node(node)

    # --- Node management --------------------------------------------------
    def _add_node(self, node: BaseNode):
        self.nodes.append(node)
        item = NodeGraphicsItem(node)
        self.scene.addItem(item)
        self.node_items[node] = item
        self._layout_nodes()
        # Select the new node
        self.scene.clearSelection()
        item.setSelected(True)

    def _layout_nodes(self):
        # Simple linear layout from left to right
        for line in self.connectors:
            self.scene.removeItem(line)
        self.connectors = []

        x_pos = 0.0
        for idx, node in enumerate(self.nodes):
            item = self.node_items.get(node)
            if item is None:
                continue
            item.setPos(x_pos, 0.0)
            x_pos += 160.0

        # Draw arrows between consecutive nodes
        for i in range(1, len(self.nodes)):
            prev_item = self.node_items[self.nodes[i - 1]]
            curr_item = self.node_items[self.nodes[i]]
            p1 = prev_item.sceneBoundingRect().center()
            p2 = curr_item.sceneBoundingRect().center()
            line_item = self.scene.addLine(p1.x(), p1.y(), p2.x(), p2.y(), QtGui.QPen(QtCore.Qt.darkGray, 2))
            self.connectors.append(line_item)

    def _current_index(self) -> int:
        if self.selected_node is None:
            return -1
        try:
            return self.nodes.index(self.selected_node)
        except ValueError:
            return -1

    # --- Toolbar handlers -------------------------------------------------
    def _on_add_laser(self):
        self._add_node(LaserNodeModel(f"Laser {len([n for n in self.nodes if isinstance(n, LaserNodeModel)] ) + 1}"))

    def _on_add_sample(self):
        self._add_node(SampleNodeModel(f"Dye {len([n for n in self.nodes if isinstance(n, SampleNodeModel)] ) + 1}"))

    def _on_add_filter(self):
        self._add_node(FilterNodeModel(f"Filter {len([n for n in self.nodes if isinstance(n, FilterNodeModel)] ) + 1}"))

    def _on_add_detector(self):
        self._add_node(DetectorNodeModel(f"Det {len([n for n in self.nodes if isinstance(n, DetectorNodeModel)] ) + 1}"))

    def _on_remove_node(self):
        idx = self._current_index()
        if idx < 0:
            return
        node = self.nodes.pop(idx)
        item = self.node_items.pop(node, None)
        if item is not None:
            self.scene.removeItem(item)
        self.selected_node = None
        self._layout_nodes()
        self._rebuild_property_editor()

    def _on_move_up(self):
        idx = self._current_index()
        if idx <= 0:
            return
        self.nodes[idx - 1], self.nodes[idx] = self.nodes[idx], self.nodes[idx - 1]
        self._layout_nodes()

    def _on_move_down(self):
        idx = self._current_index()
        if idx < 0 or idx >= len(self.nodes) - 1:
            return
        self.nodes[idx + 1], self.nodes[idx] = self.nodes[idx], self.nodes[idx + 1]
        self._layout_nodes()

    # --- Selection & properties ------------------------------------------
    def _on_scene_selection_changed(self):
        items = [it for it in self.scene.selectedItems() if isinstance(it, NodeGraphicsItem)]
        if items:
            self.selected_node = items[0].model
        else:
            self.selected_node = None
        self._rebuild_property_editor()

    def _clear_form_layout(self, layout: QtWidgets.QFormLayout):
        while layout.count():
            item = layout.takeAt(0)
            w = item.widget()
            if w is not None:
                w.deleteLater()

    def _rebuild_property_editor(self):
        self._clear_form_layout(self.prop_form)
        node = self.selected_node
        if node is None:
            return

        # Common: name
        name_edit = QtWidgets.QLineEdit(node.name)
        name_edit.editingFinished.connect(lambda n=node, e=name_edit: self._on_name_changed(n, e))
        self.prop_form.addRow("Name", name_edit)

        if isinstance(node, LaserNodeModel):
            self._add_double_spin("Wavelength (nm)", node, "wavelength", 300.0, 900.0, 1.0)
            self._add_double_spin("Power (a.u.)", node, "power", 0.0, 1e6, 10.0)
            self._add_double_spin("Bandwidth (nm)", node, "bandwidth", 0.1, 50.0, 0.1)
        elif isinstance(node, SampleNodeModel):
            self._add_double_spin("Ext. Coeff. (M^-1 cm^-1)", node, "ext_coeff", 0.0, 3e5, 1000.0)
            self._add_double_spin("Quantum Yield", node, "qy", 0.0, 1.0, 0.01)
            self._add_double_spin("Absorption Max (nm)", node, "abs_max", 300.0, 900.0, 1.0)
            self._add_double_spin("Emission Max (nm)", node, "em_max", 300.0, 900.0, 1.0)
            self._add_double_spin("Concentration (uM)", node, "conc_uM", 0.0, 1000.0, 0.1)
            self._add_double_spin("Path Length (cm)", node, "path_len_cm", 0.01, 10.0, 0.01)
            if self.db_fluorophores:
                combo = QtWidgets.QComboBox()
                combo.addItem("<none>")
                names = sorted(self.db_fluorophores.keys())
                combo.addItems(names)
                if node.db_item_name and node.db_item_name in names:
                    combo.setCurrentText(node.db_item_name)
                combo.currentTextChanged.connect(lambda text, n=node: self._on_sample_db_changed(n, text))
                self.prop_form.addRow("DB Fluorophore", combo)
        elif isinstance(node, FilterNodeModel):
            self._add_double_spin("Cutoff / Center (nm)", node, "cutoff", 300.0, 900.0, 1.0)
            combo = QtWidgets.QComboBox()
            combo.addItems(["LongPass", "ShortPass", "BandPass"])
            idx = combo.findText(node.filter_type)
            if idx >= 0:
                combo.setCurrentIndex(idx)
            combo.currentTextChanged.connect(lambda text, n=node: setattr(n, "filter_type", text))
            self.prop_form.addRow("Filter Type", combo)
            self._add_double_spin("Bandwidth (nm)", node, "bandwidth", 1.0, 300.0, 1.0)
            if self.db_filters:
                combo2 = QtWidgets.QComboBox()
                combo2.addItem("<none>")
                fnames = sorted(self.db_filters.keys())
                combo2.addItems(fnames)
                if node.db_item_name and node.db_item_name in fnames:
                    combo2.setCurrentText(node.db_item_name)
                combo2.currentTextChanged.connect(lambda text, n=node: self._on_filter_db_changed(n, text))
                self.prop_form.addRow("DB Filter", combo2)
        elif isinstance(node, DetectorNodeModel):
            self._add_double_spin("Min Wavelength (nm)", node, "min_wl", 250.0, 1000.0, 1.0)
            self._add_double_spin("Max Wavelength (nm)", node, "max_wl", 250.0, 1000.0, 1.0)

    def _add_double_spin(self, label: str, node: BaseNode, attr: str, vmin: float, vmax: float, step: float):
        sb = QtWidgets.QDoubleSpinBox()
        sb.setRange(vmin, vmax)
        sb.setSingleStep(step)
        sb.setDecimals(3)
        sb.setValue(float(getattr(node, attr)))
        sb._node = node
        sb._attr = attr
        sb.valueChanged.connect(self._on_double_param_changed)
        self.prop_form.addRow(label, sb)

    def _on_double_param_changed(self, value: float):
        sb = self.sender()
        node = getattr(sb, "_node", None)
        attr = getattr(sb, "_attr", None)
        if node is not None and attr is not None:
            setattr(node, attr, float(value))

    def _on_name_changed(self, node: BaseNode, edit: QtWidgets.QLineEdit):
        node.name = edit.text().strip() or node.name
        item = self.node_items.get(node)
        if item is not None:
            item.update()

    def _on_sample_db_changed(self, node: SampleNodeModel, text: str):
        if text == "<none>" or not text:
            node.db_item_name = None
            node.db_abs_wl = None
            node.db_abs_vals = None
            node.db_em_wl = None
            node.db_em_vals = None
            return
        entry = self.db_fluorophores.get(text)
        if not entry:
            return
        node.db_item_name = text
        spectra = entry.get("spectra", {})
        node.db_abs_wl = None
        node.db_abs_vals = None
        node.db_em_wl = None
        node.db_em_vals = None
        if "absorption" in spectra:
            wl, vals = spectra["absorption"]
            node.db_abs_wl = np.asarray(wl, dtype=float)
            node.db_abs_vals = np.asarray(vals, dtype=float)
        if "emission" in spectra:
            wl, vals = spectra["emission"]
            node.db_em_wl = np.asarray(wl, dtype=float)
            node.db_em_vals = np.asarray(vals, dtype=float)
        if SpectraDatabase is not None:
            try:
                db = SpectraDatabase()
                with db:
                    props = db.get_optical_properties(entry["item_id"])
                qy_str = props.get("Quantum Yield") or props.get("ηfl") or props.get("QY")
                if qy_str is not None:
                    try:
                        node.qy = float(qy_str)
                    except Exception:
                        pass
                ext_str = props.get("Extinction Coefficient") or props.get("εmax") or props.get("e_max")
                if ext_str is not None:
                    try:
                        node.ext_coeff = float(ext_str)
                    except Exception:
                        pass
            except Exception as exc:
                print("Error reading optical properties from DB:", exc)

    def _on_filter_db_changed(self, node: FilterNodeModel, text: str):
        if text == "<none>" or not text:
            node.db_item_name = None
            node.db_tr_wl = None
            node.db_tr_vals = None
            return
        entry = self.db_filters.get(text)
        if not entry:
            return
        node.db_item_name = text
        spectra = entry.get("spectra", {})
        if "transmission" in spectra:
            wl, vals = spectra["transmission"]
            node.db_tr_wl = np.asarray(wl, dtype=float)
            node.db_tr_vals = np.asarray(vals, dtype=float)

    # --- Calculation ------------------------------------------------------
    def _on_calculate(self):
        if not self.nodes:
            QtWidgets.QMessageBox.information(self, "No Nodes", "Please add at least a Laser and a Detector.")
            return

        # Evaluate chain
        beam: Beam | None = None
        channel_signals: dict[str, dict[str, float]] = {}

        for node in self.nodes:
            try:
                beam, signals = node.process(beam)
            except Exception as exc:
                QtWidgets.QMessageBox.critical(self, "Error", f"Error processing {node.display_name()}:\n{exc}")
                return

            for ch_name, comp_dict in signals.items():
                if ch_name not in channel_signals:
                    channel_signals[ch_name] = {}
                for comp_name, val in comp_dict.items():
                    channel_signals[ch_name][comp_name] = channel_signals[ch_name].get(comp_name, 0.0) + float(val)

        if not channel_signals:
            QtWidgets.QMessageBox.information(self, "No Signals", "No detector signals were produced.")
            for tbl in (self.table_total, self.table_exc, self.table_em):
                tbl.clear()
                tbl.setRowCount(0)
                tbl.setColumnCount(0)
            return

        # Build component list and channel list
        channels = sorted(channel_signals.keys())
        component_set = set()
        for comp_dict in channel_signals.values():
            component_set.update(comp_dict.keys())
        components = sorted(component_set)

        # Helper to fill any table
        def fill_table(table: QtWidgets.QTableWidget, row_components):
            row_components = list(row_components)
            table.clear()
            table.setRowCount(len(row_components))
            table.setColumnCount(len(channels))
            table.setHorizontalHeaderLabels(channels)
            table.setVerticalHeaderLabels(row_components)
            for r, comp in enumerate(row_components):
                for c, ch in enumerate(channels):
                    val = channel_signals.get(ch, {}).get(comp, 0.0)
                    table.setItem(r, c, QtWidgets.QTableWidgetItem(f"{val:.3e}"))
            table.resizeColumnsToContents()
            table.resizeRowsToContents()

        # Classify components: excitation vs emission
        exc_components = set()
        em_components = set()
        for n in self.nodes:
            if isinstance(n, LaserNodeModel):
                exc_components.add(n.excitation_component_name())
            elif isinstance(n, SampleNodeModel):
                em_components.add(n.emission_component_name())

        # Total matrix
        fill_table(self.table_total, components)

        # Excitation-only
        exc_rows = sorted(components and (exc_components & set(components)))
        fill_table(self.table_exc, exc_rows)

        # Emission-only
        em_rows = sorted(components and (em_components & set(components)))
        fill_table(self.table_em, em_rows)


if __name__ == "__main__":
    import sys

    app = QtWidgets.QApplication(sys.argv)
    w = CrosstalkCalculatorWidget()
    w.resize(1000, 600)
    w.show()
    sys.exit(app.exec_())
