"""
Scanner Control tab - communicates exclusively with the lf_orchestration server.
All microservice switching (Spectrometer, Reconstructor, etc.) is handled by the
orchestrator; this tab never connects to those services directly.
"""
from __future__ import annotations

import copy
import json
import os
import subprocess
import sys

from PySide6.QtCore import Qt, QThread, QTimer, Signal
from PySide6.QtGui import QFont, QColor
from PySide6.QtWidgets import (
    QWidget, QSplitter, QVBoxLayout, QHBoxLayout,
    QLabel, QPushButton, QProgressBar,
    QTextEdit, QScrollArea, QSizePolicy, QTabWidget,
    QComboBox, QLineEdit, QTableWidget, QTableWidgetItem,
    QHeaderView, QAbstractItemView,
    QDialog, QDialogButtonBox, QListWidget, QListWidgetItem,
    QFrame, QToolButton, QMessageBox, QSplitter as _QSplitter,
)

from src.clients.orchestrator_client import OrchestratorClient, OrchestratorError
from src.gui.tr_widgets import TrGroupBox, TrLabel, TrPushButton, bind_tab_text, bind_table_headers
from src.gui.workers import OrchestratorWorker
from src import i18n, theme


# ── Built-in step templates ───────────────────────────────────────────────────
# Each entry: internal name → display info + default params.
# display_en / display_ru are shown in the builder UI.
_STEP_TEMPLATES: dict[str, dict] = {
    "start_measurement": {
        "display_en": "Start Measurement",
        "display_ru": "Запуск измерения",
        "desc_en": "Submit scan parameters to the spectrometer and trigger acquisition.",
        "desc_ru": "Отправить параметры скана на спектрометр и запустить сбор данных.",
        "params": {
            "info": {
                "infostr": "custom_signal",
                "engine":  "DefaultEngine",
                "iadc": {
                    "device_model": "PS4000A",
                    "srate":        80000000,
                    "points":       [1000],
                    "n_channels":   1,
                    "averaging":    1,
                },
            }
        },
    },
    "wait_data_ready": {
        "display_en": "Wait Data Ready",
        "display_ru": "Ожидание данных",
        "desc_en": "Poll spectrometer until data from the previous measurement is available.",
        "desc_ru": "Ожидать готовности данных от предыдущего шага измерения.",
        "params": {},
    },
    "fetch_data": {
        "display_en": "Fetch Data",
        "display_ru": "Получить данные",
        "desc_en": "Download raw acquisition data from the spectrometer.",
        "desc_ru": "Скачать сырые данные с спектрометра.",
        "params": {"format": "h5"},
    },
    "run_reconstruction": {
        "display_en": "Run Reconstruction",
        "display_ru": "Реконструкция",
        "desc_en": "Send raw data to the reconstructor service and retrieve the image.",
        "desc_ru": "Передать данные в реконструктор и получить изображение.",
        "params": {
            "sequence_name": "linear",
            "digit":         "2d",
            "phase_shift":   False,
        },
    },
}


def _spec_dir() -> str:
    """Absolute path to services/spectrometer regardless of CWD."""
    # scanner_tab.py is at apps/gui/src/tabs/ — repo root is 4 levels up
    here = os.path.dirname(os.path.abspath(__file__))
    root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(here))))
    return os.path.join(root, "services", "spectrometer")


class _SpectrometerRestartWorker(QThread):
    """
    Kills pico-tcp + Django runserver then relaunches both.
    Runs entirely off the UI thread.
    """
    log    = Signal(str)
    done   = Signal(bool, str)   # (success, message)

    def run(self) -> None:
        spec = _spec_dir()
        venv = os.path.join(spec, "mvenv", "Scripts", "python.exe")
        pico = os.path.join(spec, "bin", "pico-tcp.exe")

        try:
            # 1. Kill stale pico-tcp
            self.log.emit("Stopping pico-tcp.exe…")
            subprocess.run(
                ["taskkill", "/f", "/im", "pico-tcp.exe"],
                capture_output=True,
            )

            # 2. Kill Django runserver on port 1818.
            # Use netstat (always in System32, like taskkill) instead of
            # PowerShell — its directory is a separate PATH entry that is
            # often missing. Match the listening socket by foreign address
            # "0.0.0.0:0"/"[::]:0" so we don't depend on the (localised)
            # state column text.
            self.log.emit("Stopping Django runserver…")
            try:
                r = subprocess.run(
                    ["netstat", "-ano", "-p", "TCP"],
                    capture_output=True, text=True, timeout=10,
                )
                pids = set()
                for line in r.stdout.splitlines():
                    parts = line.split()
                    # Proto  Local:1818  Foreign(0.0.0.0:0)  State  PID
                    if (len(parts) >= 5 and parts[0] == "TCP"
                            and parts[1].endswith(":1818")
                            and parts[2] in ("0.0.0.0:0", "[::]:0")
                            and parts[-1].isdigit()):
                        pids.add(parts[-1])
                for pid_str in pids:
                    subprocess.run(
                        ["taskkill", "/f", "/pid", pid_str],
                        capture_output=True,
                    )
                    self.log.emit(f"  Killed PID {pid_str}")
            except Exception as e:
                self.log.emit(f"  (port kill skipped: {e})")

            import time as _t
            _t.sleep(1)

            # 3. Run migrations (silent)
            self.log.emit("Running migrations…")
            subprocess.run(
                [venv, "manage.py", "migrate", "--noinput"],
                cwd=spec, capture_output=True, timeout=30,
            )

            # 4. Start pico-tcp
            if os.path.isfile(pico):
                self.log.emit("Starting pico-tcp.exe…")
                subprocess.Popen(
                    [pico],
                    creationflags=subprocess.CREATE_NEW_CONSOLE,
                )
            else:
                self.log.emit("  pico-tcp.exe not found — skipped")

            # 5. Start Django runserver
            self.log.emit("Starting Django runserver (0.0.0.0:1818)…")
            subprocess.Popen(
                [venv, "manage.py", "runserver", "0.0.0.0:1818", "--noreload"],
                cwd=spec,
                creationflags=subprocess.CREATE_NEW_CONSOLE,
            )

            self.done.emit(True, "Spectrometer restarted")

        except Exception as exc:
            self.done.emit(False, str(exc))


# ── Scenario builder dialog ───────────────────────────────────────────────────

class ScenarioBuilderDialog(QDialog):
    """
    Modal dialog for composing a custom scenario step-by-step.

    Left column: available step templates.
    Right column: ordered list of steps to execute.
    Bottom: JSON params editor for the selected step.
    """

    def __init__(
        self,
        extra_tasks: list[str] | None = None,
        parent: QWidget | None = None,
    ) -> None:
        super().__init__(parent)
        self.setWindowTitle("Scenario Builder")
        self.setMinimumSize(720, 520)
        self.resize(820, 580)

        # Merge server-side extra tasks into templates as "bare" entries
        self._templates = dict(_STEP_TEMPLATES)
        for t in (extra_tasks or []):
            if t not in self._templates:
                self._templates[t] = {
                    "display_en": t,
                    "display_ru": t,
                    "desc_en": "(custom task — no built-in description)",
                    "desc_ru": "(пользовательский шаг)",
                    "params": {},
                }

        # Internal step list: list of {"name": str, "params": dict}
        self._steps: list[dict] = []

        self._build_ui()

    # ── result ────────────────────────────────────────────────────────────

    def get_steps(self) -> list[dict]:
        """Return the composed step list (call after exec() == Accepted)."""
        return copy.deepcopy(self._steps)

    # ── UI ────────────────────────────────────────────────────────────────

    def _build_ui(self) -> None:
        root = QVBoxLayout(self)
        root.setSpacing(6)

        splitter = _QSplitter(Qt.Horizontal)

        # Left: template palette
        left = QWidget()
        left.setMinimumWidth(200)
        left.setMaximumWidth(260)
        ll = QVBoxLayout(left)
        ll.setContentsMargins(0, 0, 0, 0)
        ll.setSpacing(4)
        ll.addWidget(QLabel("<b>Available steps</b>"))

        self._tmpl_list = QListWidget()
        self._tmpl_list.setAlternatingRowColors(True)
        for name, info in self._templates.items():
            display = info["display_en"] if i18n.current_language() == "en" \
                      else info["display_ru"]
            item = QListWidgetItem(display)
            item.setData(Qt.UserRole, name)
            item.setToolTip(
                info["desc_en"] if i18n.current_language() == "en"
                else info["desc_ru"]
            )
            self._tmpl_list.addItem(item)
        self._tmpl_list.itemDoubleClicked.connect(self._add_selected_template)
        ll.addWidget(self._tmpl_list, stretch=1)

        btn_add = QPushButton("+ Add →")
        btn_add.clicked.connect(self._add_selected_template)
        ll.addWidget(btn_add)
        splitter.addWidget(left)

        # Right: step queue
        right = QWidget()
        rl = QVBoxLayout(right)
        rl.setContentsMargins(0, 0, 0, 0)
        rl.setSpacing(4)
        rl.addWidget(QLabel("<b>Steps to run (in order)</b>"))

        self._step_list = QListWidget()
        self._step_list.setAlternatingRowColors(True)
        self._step_list.currentRowChanged.connect(self._on_step_selected)
        rl.addWidget(self._step_list, stretch=1)

        # Row controls: move up / move down / remove
        ctrl_row = QHBoxLayout()
        ctrl_row.setSpacing(4)
        for symbol, tip, slot in [
            ("▲", "Move up",   self._move_up),
            ("▼", "Move down", self._move_down),
            ("✕", "Remove",    self._remove_step),
        ]:
            b = QPushButton(symbol)
            b.setFixedSize(28, 24)
            b.setToolTip(tip)
            b.clicked.connect(slot)
            ctrl_row.addWidget(b)
        ctrl_row.addStretch()
        rl.addLayout(ctrl_row)

        # Params editor (JSON)
        rl.addWidget(QLabel("<b>Step params (JSON)</b>"))
        self._params_edit = QTextEdit()
        self._params_edit.setFont(QFont("Courier New", 9))
        self._params_edit.setFixedHeight(130)
        self._params_edit.setPlaceholderText("Select a step to edit its params")
        self._params_edit.textChanged.connect(self._on_params_edited)
        rl.addWidget(self._params_edit)

        splitter.addWidget(right)
        splitter.setSizes([220, 500])
        root.addWidget(splitter, stretch=1)

        # Dialog buttons
        btns = QDialogButtonBox(
            QDialogButtonBox.Ok | QDialogButtonBox.Cancel
        )
        btns.button(QDialogButtonBox.Ok).setText("Load as Job")
        btns.accepted.connect(self._on_accept)
        btns.rejected.connect(self.reject)
        root.addWidget(btns)

    # ── template → step queue ─────────────────────────────────────────────

    def _add_selected_template(self) -> None:
        item = self._tmpl_list.currentItem()
        if item is None:
            return
        name = item.data(Qt.UserRole)
        tpl  = self._templates.get(name, {})
        self._steps.append({
            "name":   name,
            "params": copy.deepcopy(tpl.get("params", {})),
        })
        self._refresh_step_list()
        self._step_list.setCurrentRow(len(self._steps) - 1)

    def _refresh_step_list(self) -> None:
        self._step_list.clear()
        for idx, step in enumerate(self._steps):
            name  = step["name"]
            tpl   = self._templates.get(name, {})
            disp  = tpl.get("display_en" if i18n.current_language() == "en"
                            else "display_ru", name)
            item  = QListWidgetItem(f"{idx + 1}.  {disp}  [{name}]")
            item.setData(Qt.UserRole, idx)
            self._step_list.addItem(item)

    # ── step controls ─────────────────────────────────────────────────────

    def _current_idx(self) -> int:
        return self._step_list.currentRow()

    def _move_up(self) -> None:
        i = self._current_idx()
        if i <= 0:
            return
        self._steps[i - 1], self._steps[i] = self._steps[i], self._steps[i - 1]
        self._refresh_step_list()
        self._step_list.setCurrentRow(i - 1)

    def _move_down(self) -> None:
        i = self._current_idx()
        if i < 0 or i >= len(self._steps) - 1:
            return
        self._steps[i + 1], self._steps[i] = self._steps[i], self._steps[i + 1]
        self._refresh_step_list()
        self._step_list.setCurrentRow(i + 1)

    def _remove_step(self) -> None:
        i = self._current_idx()
        if i < 0:
            return
        self._steps.pop(i)
        self._refresh_step_list()
        self._step_list.setCurrentRow(max(0, i - 1))

    # ── params editor ─────────────────────────────────────────────────────

    def _on_step_selected(self, row: int) -> None:
        if row < 0 or row >= len(self._steps):
            self._params_edit.setPlainText("")
            return
        params = self._steps[row].get("params", {})
        self._params_edit.blockSignals(True)
        self._params_edit.setPlainText(
            json.dumps(params, indent=2, ensure_ascii=False)
        )
        self._params_edit.blockSignals(False)

    def _on_params_edited(self) -> None:
        i = self._current_idx()
        if i < 0 or i >= len(self._steps):
            return
        try:
            params = json.loads(self._params_edit.toPlainText() or "{}")
            self._steps[i]["params"] = params
            self._params_edit.setStyleSheet("")
        except json.JSONDecodeError:
            self._params_edit.setStyleSheet("border: 1px solid #c62828;")

    # ── accept ────────────────────────────────────────────────────────────

    def _on_accept(self) -> None:
        if not self._steps:
            QMessageBox.warning(self, "Empty scenario",
                                "Add at least one step before loading.")
            return
        # Validate JSON one last time
        try:
            json.loads(self._params_edit.toPlainText() or "{}")
        except json.JSONDecodeError:
            QMessageBox.warning(self, "Invalid JSON",
                                "Fix the params JSON before loading.")
            return
        self.accept()


# ── Status colors ─────────────────────────────────────────────────────────────

_SVC_POLL_MS = 8_000   # service health-check interval (ms)

_STATUS_COLORS = {
    "pending":  "#9e9e9e",
    "running":  "#e65100",
    "done":     "#2e7d32",
    "failed":   "#c62828",
}

_POLL_INTERVAL_MS = 1500


class ScannerTab(QWidget):
    """Orchestrator-based scanner control panel."""

    scan_result_ready = Signal(str)   # emitted when job finishes with a JSON output path

    def __init__(
        self,
        hw_config_path: str | None = None,
        orchestrator_url: str = "http://localhost:1717",
        seq_interp_url: str = "http://localhost:7475",
        reconstructor_url: str = "http://localhost:8081",
        spectroscopy_url: str = "http://localhost:8002",
        spectrometer_url: str = "http://localhost:1818",
        parent: QWidget | None = None,
    ) -> None:
        super().__init__(parent)

        self._hw_config_path    = hw_config_path
        self._orchestrator_url  = orchestrator_url.rstrip("/")
        # Other service URLs kept for potential future use (not used after services bar removal)
        self._seq_interp_url    = seq_interp_url.rstrip("/")
        self._reconstructor_url = reconstructor_url.rstrip("/")
        self._spectroscopy_url  = spectroscopy_url.rstrip("/")
        self._spectrometer_url  = spectrometer_url.rstrip("/")

        self._client  = OrchestratorClient(orchestrator_url)
        self._job_id  : str | None = None
        self._seq_info: dict | None = None
        self._conn_state: str = "offline"
        self._steps_data: list = []

        self._run_worker     : OrchestratorWorker | None = None
        self._poll_worker    : OrchestratorWorker | None = None
        self._restart_worker : _SpectrometerRestartWorker | None = None

        self._poll_timer = QTimer(self)
        self._poll_timer.setInterval(_POLL_INTERVAL_MS)
        self._poll_timer.timeout.connect(self._poll_status)

        # Service health polling
        self._svc_workers: dict[str, OrchestratorWorker] = {}
        self._svc_timer = QTimer(self)
        self._svc_timer.setInterval(_SVC_POLL_MS)
        self._svc_timer.timeout.connect(self._poll_all_services)

        self._build_layout()

        # Load scenarios as soon as the event loop starts
        QTimer.singleShot(500, self._on_refresh_scenarios)

        # Start service health polling shortly after startup
        QTimer.singleShot(1200, self._poll_all_services)
        self._svc_timer.start()

    # ================================================================== #
    #  Public API                                                          #
    # ================================================================== #

    def set_hw_config(self, path: str) -> None:
        self._hw_config_path = path
        self._append_log(f"HW config: {path}")

    def refresh_scenarios(self) -> None:
        """Reload scenario list (called on mode switch or explicit request)."""
        self._on_refresh_scenarios()

    def attach_job(self, job_id: str) -> None:
        """
        Called when ScanningTab starts a scan job via the orchestrator.
        Wires the job in so the operator can monitor progress here.
        """
        self._job_id = job_id
        self._job_label.setText(self._short_id(job_id))
        self._append_log(f"Scan job received from Scanning tab: {job_id}")

        self._steps_table.setRowCount(0)
        self._btn_run_all.setEnabled(False)
        self._btn_next.setEnabled(False)
        self._btn_abort.setEnabled(True)
        self._show_job_progress(True)

        self._fetch_status_once()
        self._poll_timer.start()

    def apply_seq_info(self, info_dict: dict) -> None:
        """Receive sequence info from SeqInterpTab after export."""
        self._seq_info = info_dict
        lines = []
        if "infostr" in info_dict:
            lines.append(info_dict["infostr"])
        if "time" in info_dict:
            lines.append(info_dict["time"])
        adc = info_dict.get("iadc", {})
        if "points" in adc:
            lines.append(f"ADC windows: {len(adc['points'])}")
        self._seq_info_label.setText("\n".join(lines) if lines else "-")
        self._append_log("Sequence info received from Sequence tab.")

    # -- Services strip --------------------------------------------------------

    def _build_services_strip(self) -> QWidget:
        """Thin status bar showing health of all platform services."""
        strip = QWidget()
        strip.setFixedHeight(22)
        lay = QHBoxLayout(strip)
        lay.setContentsMargins(6, 0, 6, 0)
        lay.setSpacing(0)

        svc_defs: list[tuple[str, str, str]] = [
            ("orchestrator",  "Orchestrator", self._orchestrator_url),
            ("seq_interp",    "Seq-Interp",   self._seq_interp_url),
            ("spectrometer",  "Spectrometer", self._spectrometer_url),
            ("reconstructor", "Reconstructor",self._reconstructor_url),
            ("spectroscopy",  "Spectroscopy", self._spectroscopy_url),
        ]

        self._svc_labels: dict[str, QLabel] = {}
        for i, (key, name, url) in enumerate(svc_defs):
            lbl = QLabel(f"● {name}")
            lbl.setStyleSheet("color: #555577; font-size: 10px;")
            lbl.setToolTip(url)
            self._svc_labels[key] = lbl
            lay.addWidget(lbl)
            if i < len(svc_defs) - 1:
                sep = QLabel("  |  ")
                sep.setStyleSheet("color: #2a2a44; font-size: 10px;")
                lay.addWidget(sep)

        lay.addStretch()

        btn_refresh = QPushButton("↻")
        btn_refresh.setFixedSize(18, 18)
        btn_refresh.setToolTip("Refresh service status")
        btn_refresh.setStyleSheet(
            "QPushButton{background:transparent;color:#555577;"
            "border:1px solid #2a2a44;border-radius:2px;font-size:10px;}"
            "QPushButton:hover{color:#aaaacc;border-color:#555577;}"
        )
        btn_refresh.clicked.connect(self._poll_all_services)
        lay.addWidget(btn_refresh)

        self._svc_strip = strip
        return strip

    # -- Service health polling ------------------------------------------------

    def _poll_all_services(self) -> None:
        import httpx

        def _check(url: str) -> bool:
            try:
                r = httpx.get(url, timeout=3.0)
                return r.status_code < 500
            except Exception:
                return False

        checks: dict[str, str] = {
            "orchestrator":  f"{self._orchestrator_url}/health",
            "seq_interp":    f"{self._seq_interp_url}/health",
            "spectrometer":  f"{self._orchestrator_url}/spectrometer/health",
            "reconstructor": f"{self._reconstructor_url}/health",
            "spectroscopy":  f"{self._spectroscopy_url}/health",
        }

        for key, url in checks.items():
            existing = self._svc_workers.get(key)
            if existing and existing.isRunning():
                continue
            worker = OrchestratorWorker(_check, url)
            worker.finished.connect(
                lambda ok, k=key: self._on_svc_checked(k, bool(ok))
            )
            worker.error.connect(
                lambda _msg, k=key: self._on_svc_checked(k, False)
            )
            worker.start()
            self._svc_workers[key] = worker

    def _on_svc_checked(self, key: str, online: bool) -> None:
        lbl = self._svc_labels.get(key)
        if lbl is None:
            return
        # preserve display name (everything after "● ")
        name = lbl.text()[2:]
        if online:
            lbl.setStyleSheet("color: #2e7d32; font-size: 10px;")
        else:
            lbl.setStyleSheet("color: #c62828; font-size: 10px;")

    def apply_theme(self) -> None:
        pass

    def retranslate_ui(self) -> None:
        self._status_label.setText(i18n.tr(self._conn_state))

    # ================================================================== #
    #  Layout                                                              #
    # ================================================================== #

    def _build_layout(self) -> None:
        root = QVBoxLayout(self)
        root.setContentsMargins(6, 6, 6, 6)
        root.setSpacing(6)

        root.addWidget(self._build_connection_bar())

        split = QSplitter(Qt.Horizontal)
        split.setChildrenCollapsible(False)
        split.addWidget(self._build_left_panel())
        split.addWidget(self._build_right_panel())
        split.setSizes([260, 740])
        root.addWidget(split, stretch=1)

        root.addWidget(self._build_services_strip())

    # -- Connection bar ----------------------------------------------------

    def _build_connection_bar(self) -> QWidget:
        bar = QWidget()
        lay = QHBoxLayout(bar)
        lay.setContentsMargins(0, 0, 0, 0)
        lay.setSpacing(6)

        lay.addWidget(TrLabel("url_label"))

        self._url_edit = QLineEdit(self._client.base_url)
        self._url_edit.setMaximumWidth(260)
        lay.addWidget(self._url_edit)

        self._btn_connect = TrPushButton("btn_connect")
        self._btn_connect.setMinimumWidth(90)
        self._btn_connect.clicked.connect(self._on_connect)
        lay.addWidget(self._btn_connect)

        self._conn_progress = QProgressBar()
        self._conn_progress.setRange(0, 0)
        self._conn_progress.setFixedWidth(80)
        self._conn_progress.setVisible(False)
        lay.addWidget(self._conn_progress)

        self._status_label = QLabel(i18n.tr("offline"))
        self._status_label.setStyleSheet("color: #9e9e9e; font-weight: bold;")
        lay.addWidget(self._status_label)

        lay.addStretch()
        return bar

    # -- Left panel --------------------------------------------------------

    def _build_left_panel(self) -> QWidget:
        container = QWidget()
        container.setMinimumWidth(200)
        container.setMaximumWidth(300)
        lay = QVBoxLayout(container)
        lay.setContentsMargins(4, 4, 4, 4)
        lay.setSpacing(8)

        lay.addWidget(self._build_scenario_group())
        lay.addWidget(self._build_job_group())
        lay.addWidget(self._build_seq_info_group())
        lay.addWidget(self._build_spectrometer_group())
        lay.addStretch()

        scroll = QScrollArea()
        scroll.setWidget(container)
        scroll.setWidgetResizable(True)
        scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
        return scroll

    def _build_scenario_group(self) -> QWidget:
        grp = TrGroupBox("grp_scenario")
        lay = QVBoxLayout(grp)
        lay.setSpacing(4)

        # Combo + refresh button
        row = QHBoxLayout()
        row.setSpacing(4)
        self._scenario_combo = QComboBox()
        self._scenario_combo.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
        row.addWidget(self._scenario_combo)

        self._btn_refresh = QPushButton("↻")
        self._btn_refresh.setFixedSize(26, 26)
        self._btn_refresh.setToolTip(i18n.tr("btn_refresh"))
        self._btn_refresh.clicked.connect(self._on_refresh_scenarios)
        row.addWidget(self._btn_refresh)
        lay.addLayout(row)

        # Load template + build custom — side by side
        action_row = QHBoxLayout()
        action_row.setSpacing(4)

        self._btn_load = TrPushButton("btn_load_scenario")
        self._btn_load.setMinimumHeight(28)
        self._btn_load.clicked.connect(self._on_load_scenario)
        action_row.addWidget(self._btn_load)

        self._btn_build = QPushButton("+")
        self._btn_build.setFixedSize(28, 28)
        self._btn_build.setToolTip(i18n.tr("btn_build_scenario"))
        self._btn_build.setFont(QFont("Arial", 13))
        self._btn_build.clicked.connect(self._on_build_scenario)
        action_row.addWidget(self._btn_build)

        lay.addLayout(action_row)
        return grp

    def _build_job_group(self) -> QWidget:
        grp = TrGroupBox("grp_job")
        lay = QVBoxLayout(grp)
        lay.setSpacing(4)

        self._job_label = QLabel(i18n.tr("no_job"))
        self._job_label.setFont(QFont("Courier New", 8))
        self._job_label.setWordWrap(True)
        self._job_label.setStyleSheet("color: #666688;")
        lay.addWidget(self._job_label)

        # Progress bar: shows X / N steps
        self._job_progress = QProgressBar()
        self._job_progress.setRange(0, 1)
        self._job_progress.setValue(0)
        self._job_progress.setTextVisible(True)
        self._job_progress.setFormat("%v / %m")
        self._job_progress.setFixedHeight(14)
        self._job_progress.setVisible(False)
        lay.addWidget(self._job_progress)

        # Job overall status text (running / done / failed)
        self._job_status_label = QLabel()
        self._job_status_label.setFont(QFont("Courier New", 8))
        self._job_status_label.setVisible(False)
        lay.addWidget(self._job_status_label)

        # Run All — primary execution button
        self._btn_run_all = TrPushButton("btn_run_all")
        self._btn_run_all.setMinimumHeight(30)
        self._btn_run_all.setEnabled(False)
        self._btn_run_all.clicked.connect(self._on_run_all)
        lay.addWidget(self._btn_run_all)

        # Next Step + Abort — secondary controls side by side
        secondary = QHBoxLayout()
        secondary.setSpacing(4)
        self._btn_next = TrPushButton("btn_next_step")
        self._btn_next.setMinimumHeight(26)
        self._btn_next.setEnabled(False)
        self._btn_next.clicked.connect(self._on_next_step)
        secondary.addWidget(self._btn_next)

        self._btn_abort = TrPushButton("btn_abort")
        self._btn_abort.setMinimumHeight(26)
        self._btn_abort.setEnabled(False)
        self._btn_abort.clicked.connect(self._on_abort)
        secondary.addWidget(self._btn_abort)
        lay.addLayout(secondary)

        return grp

    def _build_seq_info_group(self) -> QWidget:
        grp = TrGroupBox("grp_seq_info")
        lay = QVBoxLayout(grp)
        self._seq_info_label = QLabel(i18n.tr("no_seq_loaded"))
        self._seq_info_label.setWordWrap(True)
        self._seq_info_label.setStyleSheet("color: #666688; font-style: italic;")
        self._seq_info_label.setFont(QFont("Courier New", 8))
        lay.addWidget(self._seq_info_label)
        return grp

    def _build_spectrometer_group(self) -> QWidget:
        grp = TrGroupBox("grp_spectrometer")
        grp.setTitle("Спектрометр")
        lay = QVBoxLayout(grp)
        lay.setSpacing(6)

        # Status row
        status_row = QHBoxLayout()
        self._spec_status_lbl = QLabel("● статус неизвестен")
        self._spec_status_lbl.setStyleSheet("color: #555577; font-size: 10px;")
        status_row.addWidget(self._spec_status_lbl, stretch=1)

        btn_check = QPushButton("↻")
        btn_check.setFixedSize(20, 20)
        btn_check.setToolTip("Проверить доступность спектрометра")
        btn_check.setStyleSheet(
            "QPushButton{background:transparent;color:#555577;"
            "border:1px solid #2a2a44;border-radius:3px;font-size:11px;}"
            "QPushButton:hover{color:#aaaacc;border-color:#555577;}"
        )
        btn_check.clicked.connect(self._check_spectrometer)
        status_row.addWidget(btn_check)
        lay.addLayout(status_row)

        # Restart button
        self._btn_restart_spec = QPushButton("⟳  Перезапустить спектрометр")
        self._btn_restart_spec.setToolTip(
            "Останавливает pico-tcp.exe и Django runserver,\n"
            "затем запускает оба снова."
        )
        self._btn_restart_spec.setMinimumHeight(28)
        self._btn_restart_spec.setStyleSheet(
            "QPushButton{background:#1a2a1a;color:#88cc88;"
            "border:1px solid #336633;border-radius:4px;font-size:11px;}"
            "QPushButton:hover{background:#1e3a1e;}"
            "QPushButton:disabled{color:#444;border-color:#333;}"
        )
        self._btn_restart_spec.clicked.connect(self._restart_spectrometer)
        lay.addWidget(self._btn_restart_spec)

        # Progress / log
        self._spec_restart_log = QLabel("")
        self._spec_restart_log.setWordWrap(True)
        self._spec_restart_log.setStyleSheet("color: #888aaa; font-size: 9px;")
        self._spec_restart_log.setFont(QFont("Courier New", 8))
        self._spec_restart_log.setVisible(False)
        lay.addWidget(self._spec_restart_log)

        return grp

    def _check_spectrometer(self) -> None:
        import httpx
        try:
            r = httpx.get(f"{self._spectrometer_url}/api/", timeout=3.0)
            if r.status_code < 500:
                self._spec_status_lbl.setText("● онлайн")
                self._spec_status_lbl.setStyleSheet("color: #4caf50; font-size: 10px;")
            else:
                self._spec_status_lbl.setText(f"● HTTP {r.status_code}")
                self._spec_status_lbl.setStyleSheet("color: #ff9800; font-size: 10px;")
        except Exception:
            self._spec_status_lbl.setText("● недоступен")
            self._spec_status_lbl.setStyleSheet("color: #f44336; font-size: 10px;")

    def _restart_spectrometer(self) -> None:
        if self._restart_worker and self._restart_worker.isRunning():
            return
        self._btn_restart_spec.setEnabled(False)
        self._spec_restart_log.setText("Перезапуск…")
        self._spec_restart_log.setVisible(True)
        self._spec_status_lbl.setText("● перезапуск…")
        self._spec_status_lbl.setStyleSheet("color: #ff9800; font-size: 10px;")

        self._restart_worker = _SpectrometerRestartWorker(self)
        self._restart_worker.log.connect(self._on_restart_log)
        self._restart_worker.done.connect(self._on_restart_done)
        self._restart_worker.start()

    def _on_restart_log(self, msg: str) -> None:
        self._spec_restart_log.setText(msg)

    def _on_restart_done(self, success: bool, msg: str) -> None:
        self._btn_restart_spec.setEnabled(True)
        self._spec_restart_log.setText(msg)
        if success:
            self._spec_status_lbl.setText("● запущен")
            self._spec_status_lbl.setStyleSheet("color: #ff9800; font-size: 10px;")
            # Check again after 3 s to confirm Django is up
            QTimer.singleShot(3000, self._check_spectrometer)
        else:
            self._spec_status_lbl.setText("● ошибка")
            self._spec_status_lbl.setStyleSheet("color: #f44336; font-size: 10px;")

    # -- Right panel -------------------------------------------------------

    def _build_right_panel(self) -> QWidget:
        panel = QWidget()
        lay = QVBoxLayout(panel)
        lay.setContentsMargins(4, 4, 4, 4)
        lay.setSpacing(6)

        # Steps table
        self._steps_table = QTableWidget(0, 3)
        self._steps_table.setHorizontalHeaderLabels([
            i18n.tr("col_step"), i18n.tr("col_status"), i18n.tr("col_result")
        ])
        bind_table_headers(self._steps_table, ["col_step", "col_status", "col_result"])
        hdr = self._steps_table.horizontalHeader()
        hdr.setSectionResizeMode(0, QHeaderView.ResizeToContents)
        hdr.setSectionResizeMode(1, QHeaderView.ResizeToContents)
        hdr.setSectionResizeMode(2, QHeaderView.Stretch)
        self._steps_table.verticalHeader().setVisible(False)
        self._steps_table.setEditTriggers(QAbstractItemView.NoEditTriggers)
        self._steps_table.setSelectionBehavior(QAbstractItemView.SelectRows)
        self._steps_table.setFont(QFont("Courier New", 9))
        self._steps_table.currentCellChanged.connect(
            lambda row, *_: self._on_step_selected(row)
        )
        lay.addWidget(self._steps_table, stretch=2)

        # Detail tabs
        bottom_tabs = QTabWidget()
        self._bottom_tabs = bottom_tabs

        self._step_result_view = QTextEdit()
        self._step_result_view.setReadOnly(True)
        self._step_result_view.setFont(QFont("Courier New", 9))
        bottom_tabs.addTab(self._step_result_view, i18n.tr("tab_step_result"))
        bind_tab_text(bottom_tabs, 0, "tab_step_result")

        self._step_params_view = QTextEdit()
        self._step_params_view.setReadOnly(True)
        self._step_params_view.setFont(QFont("Courier New", 9))
        bottom_tabs.addTab(self._step_params_view, i18n.tr("tab_step_params"))
        bind_tab_text(bottom_tabs, 1, "tab_step_params")

        self._seq_info_view = QTextEdit()
        self._seq_info_view.setReadOnly(True)
        self._seq_info_view.setFont(QFont("Courier New", 9))
        bottom_tabs.addTab(self._seq_info_view, i18n.tr("tab_seq_info_view"))
        bind_tab_text(bottom_tabs, 2, "tab_seq_info_view")

        self._log_view = QTextEdit()
        self._log_view.setReadOnly(True)
        self._log_view.setFont(QFont("Courier New", 9))
        bottom_tabs.addTab(self._log_view, i18n.tr("tab_log"))
        bind_tab_text(bottom_tabs, 3, "tab_log")

        lay.addWidget(bottom_tabs, stretch=1)
        return panel

    # ================================================================== #
    #  Connection                                                          #
    # ================================================================== #

    def _on_connect(self) -> None:
        url = self._url_edit.text().strip()
        if url:
            self._client = OrchestratorClient(url)

        self._btn_connect.setEnabled(False)
        self._conn_progress.setVisible(True)

        worker = OrchestratorWorker(self._client.healthcheck)
        worker.finished.connect(self._on_healthcheck_done)
        worker.error.connect(self._on_healthcheck_error)
        worker.start()
        self._hc_worker = worker

    def _on_healthcheck_done(self, ok: object) -> None:
        self._btn_connect.setEnabled(True)
        self._conn_progress.setVisible(False)
        if ok:
            self._conn_state = "online"
            self._status_label.setText(i18n.tr("online"))
            self._status_label.setStyleSheet("color: #2e7d32; font-weight: bold;")
            self._append_log(f"Connected: {self._client.base_url}")
            self._on_refresh_scenarios()
        else:
            self._conn_state = "offline"
            self._status_label.setText(i18n.tr("offline"))
            self._status_label.setStyleSheet("color: #9e9e9e; font-weight: bold;")
            self._append_log("Orchestrator not reachable.")

    def _on_healthcheck_error(self, msg: str) -> None:
        self._btn_connect.setEnabled(True)
        self._conn_progress.setVisible(False)
        self._conn_state = "conn_error"
        self._status_label.setText(i18n.tr("conn_error"))
        self._status_label.setStyleSheet("color: #c62828; font-weight: bold;")
        self._append_log(f"Connect error: {msg}")

    # ================================================================== #
    #  Scenario listing                                                    #
    # ================================================================== #

    def _on_refresh_scenarios(self) -> None:
        worker = OrchestratorWorker(self._client.list_scenarios)
        worker.finished.connect(self._on_scenarios_loaded)
        worker.error.connect(lambda msg: self._append_log(f"List error: {msg}"))
        worker.start()
        self._list_worker = worker

    def _on_scenarios_loaded(self, scenarios: object) -> None:
        items = list(scenarios or [])
        self._scenario_combo.clear()
        self._scenario_combo.addItems(items)
        self._append_log(f"Scenarios: {items}")

    # ================================================================== #
    #  Job control                                                         #
    # ================================================================== #

    def _on_build_scenario(self) -> None:
        """Open the scenario builder dialog; on accept, submit as a custom job."""
        # Fetch server-side tasks in background so the dialog can show extras
        extra: list[str] = []
        try:
            extra = self._client.get_task_registry()
        except Exception:
            pass  # offline or old server — builder still works with built-ins

        dlg = ScenarioBuilderDialog(extra_tasks=extra, parent=self)
        if dlg.exec() != QDialog.Accepted:
            return

        steps = dlg.get_steps()
        self._append_log(f"Building custom scenario: {[s['name'] for s in steps]}")

        worker = OrchestratorWorker(self._client.create_scenario, steps)
        worker.finished.connect(self._on_scenario_loaded)
        worker.error.connect(lambda msg: self._append_log(f"Build error: {msg}"))
        worker.start()
        self._load_worker = worker

    def _on_load_scenario(self) -> None:
        scenario_id = self._scenario_combo.currentText()
        if not scenario_id:
            self._append_log("No scenario selected.")
            return

        param_overrides = None
        if self._seq_info:
            param_overrides = {"start_measurement": {"info": self._seq_info}}

        self._append_log(f"Loading scenario '{scenario_id}'...")
        worker = OrchestratorWorker(
            self._client.load_scenario, scenario_id, param_overrides
        )
        worker.finished.connect(self._on_scenario_loaded)
        worker.error.connect(lambda msg: self._append_log(f"Load error: {msg}"))
        worker.start()
        self._load_worker = worker

    def _on_scenario_loaded(self, job_id: object) -> None:
        self._job_id = str(job_id)
        self._job_label.setText(self._short_id(self._job_id))
        self._append_log(f"Job created: {self._job_id}")
        self._btn_run_all.setEnabled(True)
        self._btn_next.setEnabled(True)
        self._btn_abort.setEnabled(False)
        self._steps_table.setRowCount(0)
        self._show_job_progress(True)
        self._fetch_status_once()

    def _on_run_all(self) -> None:
        if not self._job_id:
            return
        self._append_log("Running all steps...")
        self._btn_run_all.setEnabled(False)
        self._btn_next.setEnabled(False)
        self._btn_abort.setEnabled(True)

        self._run_worker = OrchestratorWorker(self._client.run_all, self._job_id)
        self._run_worker.finished.connect(self._on_run_all_done)
        self._run_worker.error.connect(self._on_worker_error)
        self._run_worker.start()
        self._poll_timer.start()

    def _on_run_all_done(self, result: object) -> None:
        self._poll_timer.stop()
        self._btn_abort.setEnabled(False)
        self._append_log("Run all complete.")
        if isinstance(result, dict) and "steps" in result:
            self._update_steps_table(result["steps"])

    def _on_next_step(self) -> None:
        if not self._job_id:
            return
        worker = OrchestratorWorker(self._client.next_step, self._job_id)
        worker.finished.connect(self._on_next_done)
        worker.error.connect(self._on_worker_error)
        worker.start()
        self._next_worker = worker

    def _on_next_done(self, result: object) -> None:
        self._append_log("Step executed.")
        self._fetch_status_once()

    def _on_abort(self) -> None:
        self._poll_timer.stop()
        if self._run_worker and self._run_worker.isRunning():
            self._run_worker.terminate()
        self._btn_run_all.setEnabled(True)
        self._btn_next.setEnabled(True)
        self._btn_abort.setEnabled(False)
        self._append_log("Aborted.")

    def _on_worker_error(self, msg: str) -> None:
        self._poll_timer.stop()
        self._btn_run_all.setEnabled(True)
        self._btn_next.setEnabled(True)
        self._btn_abort.setEnabled(False)
        self._append_log(f"Error: {msg}")

    # ================================================================== #
    #  Polling                                                             #
    # ================================================================== #

    def _fetch_status_once(self) -> None:
        if not self._job_id:
            return
        worker = OrchestratorWorker(self._client.get_status, self._job_id)
        worker.finished.connect(self._on_status_received)
        worker.error.connect(lambda msg: self._append_log(f"Poll error: {msg}"))
        worker.start()
        self._status_worker = worker

    def _poll_status(self) -> None:
        if self._poll_worker and self._poll_worker.isRunning():
            return
        self._poll_worker = OrchestratorWorker(self._client.get_status, self._job_id)
        self._poll_worker.finished.connect(self._on_status_received)
        self._poll_worker.error.connect(lambda msg: None)
        self._poll_worker.start()

    def _on_status_received(self, status: object) -> None:
        if not isinstance(status, dict):
            return
        steps = status.get("steps", [])
        self._update_steps_table(steps)
        self._update_job_status_display(status.get("status", ""), steps)

        job_status = status.get("status", "")
        if job_status == "done" or job_status.startswith("failed"):
            self._poll_timer.stop()
            self._btn_abort.setEnabled(False)
            self._append_log(f"Job finished: {job_status}")
            if job_status == "done":
                self._emit_result_if_found(steps)

    def _emit_result_if_found(self, steps: list) -> None:
        for step in steps:
            result = step.get("result") or {}
            if not isinstance(result, dict):
                continue
            for key in ("json_path", "output_path", "output", "path", "file"):
                val = result.get(key, "")
                if isinstance(val, str) and val.endswith(".json"):
                    self.scan_result_ready.emit(val)
                    return

    # ================================================================== #
    #  Steps table                                                         #
    # ================================================================== #

    def _update_steps_table(self, steps: list) -> None:
        current_row = self._steps_table.currentRow()
        self._steps_table.setRowCount(len(steps))
        for row, step in enumerate(steps):
            name   = step.get("name", "")
            status = step.get("status", "pending").lower()
            result = step.get("result", "")
            result_str = json.dumps(result, default=str)[:120] if result else ""

            name_item   = QTableWidgetItem(name)
            status_item = QTableWidgetItem(status)
            result_item = QTableWidgetItem(result_str)

            color = QColor(_STATUS_COLORS.get(status, "#9e9e9e"))
            for item in (name_item, status_item, result_item):
                item.setForeground(color)

            self._steps_table.setItem(row, 0, name_item)
            self._steps_table.setItem(row, 1, status_item)
            self._steps_table.setItem(row, 2, result_item)

        if current_row >= 0:
            self._steps_table.setCurrentCell(current_row, 0)

        self._steps_data = steps

    def _on_step_selected(self, row: int) -> None:
        if row < 0 or row >= len(self._steps_data):
            return
        step = self._steps_data[row]

        result = step.get("result", None)
        self._step_result_view.setPlainText(
            json.dumps(result, indent=2, default=str) if result is not None else ""
        )

        params = step.get("params", None)
        self._step_params_view.setPlainText(
            json.dumps(params, indent=2, default=str) if params else ""
        )

    # ================================================================== #
    #  Job progress display                                                #
    # ================================================================== #

    def _show_job_progress(self, visible: bool) -> None:
        self._job_progress.setVisible(visible)
        self._job_status_label.setVisible(visible)

    def _update_job_status_display(self, job_status: str, steps: list) -> None:
        """Update the progress bar and status label in the job group."""
        total = len(steps)
        done  = sum(1 for s in steps if s.get("status", "").lower() == "done")

        if total > 0:
            self._job_progress.setMaximum(total)
            self._job_progress.setValue(done)

        color = _STATUS_COLORS.get(job_status.split(":")[0].strip(), "#9e9e9e")
        self._job_status_label.setText(job_status)
        self._job_status_label.setStyleSheet(f"color: {color};")

    # ================================================================== #
    #  Log                                                                 #
    # ================================================================== #

    def _append_log(self, msg: str) -> None:
        self._log_view.append(msg)
        if self._seq_info is not None:
            self._seq_info_view.setPlainText(
                json.dumps(self._seq_info, indent=2, default=str)
            )

    # ================================================================== #
    #  Helpers                                                             #
    # ================================================================== #

    @staticmethod
    def _short_id(job_id: str, n: int = 20) -> str:
        return job_id[:n] + "..." if len(job_id) > n else job_id
