"""hik_camera_ros2_driver 노출/게인/ROI 파라미터 조절 패널.

카메라 노드(hik_camera_cam1/cam2/cam3)의 ROS 2 파라미터 서비스(get_parameters/
set_parameters)를 직접 호출해서 슬라이더/스핀박스로 값을 읽고 쓴다. ROI(측광 제외
상단 비율)는 카메라의 실시간 이미지 위에 경계선을 오버레이해서 눈으로 보면서
조절할 수 있게 했다.

실행:
    ros2 run hik_camera_panel panel_node

전제:
    - hik_camera_cam1/cam2/cam3 노드가 이미 떠 있어야 한다 (안 떠 있으면 "새로고침"/
      슬라이더 조작 시 상태바에 실패 메시지가 뜬다).
    - sync_role / sync_master_camera_ns는 조회만 가능하고 편집은 막아뒀다 — 드라이버가
      publisher/subscriber를 노드 시작 시 한 번만 만들기 때문에 런타임에 값을 바꿔도
      반영되지 않는다 (초기화 흐름을 다시 태우려면 노드 재시작 필요).
"""

import sys
import threading

import rclpy
from rclpy.node import Node
from rclpy.qos import qos_profile_sensor_data

from rcl_interfaces.msg import Parameter, ParameterType, ParameterValue
from rcl_interfaces.srv import GetParameters, SetParameters

from sensor_msgs.msg import Image

from python_qt_binding.QtCore import QObject, Qt, pyqtSignal
from python_qt_binding.QtGui import QColor, QImage, QPainter, QPen, QPixmap
from python_qt_binding.QtWidgets import (
    QApplication,
    QCheckBox,
    QDoubleSpinBox,
    QFormLayout,
    QGroupBox,
    QHBoxLayout,
    QLabel,
    QLineEdit,
    QMainWindow,
    QPushButton,
    QSlider,
    QSpinBox,
    QStatusBar,
    QTabWidget,
    QVBoxLayout,
    QWidget,
)

from hik_camera_panel.param_spec import PARAM_SPEC_BY_NAME, PARAM_SPECS, SLIDER_STEPS, \
    slider_to_value, value_to_slider

# (node_name, camera_name) — node_name은 파라미터 서비스(/<node_name>/set_parameters)용,
# camera_name은 이미지 토픽(/<camera_name>/image)용. hik_camera_triple_launch.py /
# camera_params_cam{1,2,3}.yaml과 일치해야 한다.
DEFAULT_CAMERAS = [
    ('hik_camera_cam1', 'cam1'),
    ('hik_camera_cam2', 'cam2'),
    ('hik_camera_cam3', 'cam3'),
]


def make_parameter_msg(name, type_str, value):
    pv = ParameterValue()
    if type_str == 'bool':
        pv.type = ParameterType.PARAMETER_BOOL
        pv.bool_value = bool(value)
    elif type_str == 'int':
        pv.type = ParameterType.PARAMETER_INTEGER
        pv.integer_value = int(round(value))
    elif type_str == 'double':
        pv.type = ParameterType.PARAMETER_DOUBLE
        pv.double_value = float(value)
    else:
        pv.type = ParameterType.PARAMETER_STRING
        pv.string_value = str(value)
    msg = Parameter()
    msg.name = name
    msg.value = pv
    return msg


def parameter_value_to_python(pv):
    if pv.type == ParameterType.PARAMETER_BOOL:
        return pv.bool_value
    if pv.type == ParameterType.PARAMETER_INTEGER:
        return pv.integer_value
    if pv.type == ParameterType.PARAMETER_DOUBLE:
        return pv.double_value
    if pv.type == ParameterType.PARAMETER_STRING:
        return pv.string_value
    return None


class RosBridge(QObject):
    """rclpy 노드를 백그라운드 스레드에서 spin하고, 결과는 Qt 시그널로 GUI 스레드에 넘긴다.

    파라미터 서비스 호출은 항상 call_async + add_done_callback으로 비동기 처리한다.
    add_done_callback은 spin 스레드에서 실행되므로, 그 안에서 위젯을 직접 건드리지 않고
    시그널만 emit한다 (Qt가 큐잉해서 GUI 스레드에서 슬롯을 실행해준다).
    """

    params_fetched = pyqtSignal(str, dict)              # node_name, {param_name: value}
    param_set_result = pyqtSignal(str, str, bool, str)  # node_name, param_name, ok, reason
    image_received = pyqtSignal(str, QImage)             # camera_name, image

    def __init__(self):
        super().__init__()
        self._node = Node('hik_camera_panel')
        self._set_clients = {}
        self._get_clients = {}
        self._image_subs = {}
        self._spin_thread = threading.Thread(target=self._spin, daemon=True)
        self._spin_thread.start()

    def _spin(self):
        rclpy.spin(self._node)

    # -- 서비스 클라이언트 --------------------------------------------------

    def _set_client(self, node_name):
        if node_name not in self._set_clients:
            self._set_clients[node_name] = self._node.create_client(
                SetParameters, f'/{node_name}/set_parameters')
        return self._set_clients[node_name]

    def _get_client(self, node_name):
        if node_name not in self._get_clients:
            self._get_clients[node_name] = self._node.create_client(
                GetParameters, f'/{node_name}/get_parameters')
        return self._get_clients[node_name]

    def fetch_parameters(self, node_name, specs):
        client = self._get_client(node_name)
        if not client.service_is_ready():
            self.param_set_result.emit(
                node_name, '(새로고침)', False,
                '파라미터 서비스에 연결할 수 없음 — 노드가 실행 중인지 확인하세요')
            return

        req = GetParameters.Request()
        req.names = [s['name'] for s in specs]
        future = client.call_async(req)

        def _done(fut):
            try:
                resp = fut.result()
            except Exception as exc:  # noqa: BLE001 - 서비스 호출 자체 실패를 그대로 보고
                self.param_set_result.emit(node_name, '(새로고침)', False, str(exc))
                return
            values = {}
            for spec, pv in zip(specs, resp.values):
                values[spec['name']] = parameter_value_to_python(pv)
            self.params_fetched.emit(node_name, values)

        future.add_done_callback(_done)

    def set_parameter(self, node_name, name, type_str, value):
        client = self._set_client(node_name)
        if not client.service_is_ready():
            self.param_set_result.emit(
                node_name, name, False,
                '파라미터 서비스에 연결할 수 없음 — 노드가 실행 중인지 확인하세요')
            return

        req = SetParameters.Request()
        req.parameters = [make_parameter_msg(name, type_str, value)]
        future = client.call_async(req)

        def _done(fut):
            try:
                resp = fut.result()
            except Exception as exc:  # noqa: BLE001
                self.param_set_result.emit(node_name, name, False, str(exc))
                return
            result = resp.results[0]
            self.param_set_result.emit(node_name, name, result.successful, result.reason)

        future.add_done_callback(_done)

    # -- 이미지 구독 ----------------------------------------------------------

    def subscribe_image(self, camera_name):
        if camera_name in self._image_subs:
            return

        def _cb(msg):
            if msg.encoding != 'rgb8':
                return
            qimg = QImage(
                bytes(msg.data), msg.width, msg.height, msg.step, QImage.Format_RGB888).copy()
            self.image_received.emit(camera_name, qimg)

        self._image_subs[camera_name] = self._node.create_subscription(
            Image, f'/{camera_name}/image', _cb, qos_profile_sensor_data)

    def shutdown(self):
        rclpy.shutdown()


class ParamRow(QWidget):
    """파라미터 하나를 표시하는 한 줄: bool=체크박스, string=텍스트박스(읽기전용 가능),
    나머지(int/double)=슬라이더+스핀박스 동시 제공.

    - previewChanged: 슬라이더 드래그 중(아직 손 안 뗌) 매번 emit — 로컬 미리보기(ROI 오버레이
      등)만 갱신하고 아직 카메라에는 보내지 않음.
    - valueEdited: 슬라이더에서 손을 떼거나, 스핀박스 편집을 마치거나, 체크박스를 토글했을 때
      emit — 이때 실제로 ros2 파라미터를 설정한다.
    """

    valueEdited = pyqtSignal(str, object)
    previewChanged = pyqtSignal(str, object)

    def __init__(self, spec, parent=None):
        super().__init__(parent)
        self.spec = spec
        self._suppress = False
        layout = QHBoxLayout(self)
        layout.setContentsMargins(0, 0, 0, 0)

        self.checkbox = None
        self.line_edit = None
        self.slider = None
        self.spin = None

        if spec['type'] == 'bool':
            self.checkbox = QCheckBox()
            self.checkbox.toggled.connect(self._on_bool_changed)
            layout.addWidget(self.checkbox)
        elif spec['type'] == 'string':
            self.line_edit = QLineEdit()
            self.line_edit.setReadOnly(spec.get('readonly', False))
            if spec.get('readonly'):
                self.line_edit.setToolTip('런타임 변경 미지원 — 노드 재시작 필요')
            layout.addWidget(self.line_edit)
        else:
            self.slider = QSlider(Qt.Horizontal)
            self.slider.setRange(0, SLIDER_STEPS)
            if spec['type'] == 'int':
                self.spin = QSpinBox()
                self.spin.setRange(int(spec['min']), int(spec['max']))
            else:
                self.spin = QDoubleSpinBox()
                self.spin.setRange(spec['min'], spec['max'])
                decimals = spec.get('decimals', 2)
                self.spin.setDecimals(decimals)
                self.spin.setSingleStep(10 ** (-decimals) if decimals > 0 else 1.0)
            layout.addWidget(self.slider, 3)
            layout.addWidget(self.spin, 1)

            self.slider.sliderMoved.connect(self._on_slider_moved)
            self.slider.sliderReleased.connect(self._on_slider_released)
            self.spin.editingFinished.connect(self._on_spin_edited)

    def set_value(self, value):
        self._suppress = True
        try:
            if self.checkbox is not None:
                self.checkbox.setChecked(bool(value))
            elif self.line_edit is not None:
                self.line_edit.setText(str(value))
            else:
                self.spin.setValue(value)
                self.slider.setValue(value_to_slider(
                    value, self.spec['min'], self.spec['max'], self.spec.get('log_scale', False)))
        finally:
            self._suppress = False

    def _on_bool_changed(self, checked):
        if not self._suppress:
            self.valueEdited.emit(self.spec['name'], checked)

    def _on_slider_moved(self, pos):
        value = slider_to_value(
            pos, self.spec['min'], self.spec['max'], self.spec.get('log_scale', False))
        self._suppress = True
        self.spin.setValue(value)
        self._suppress = False
        self.previewChanged.emit(self.spec['name'], value)

    def _on_slider_released(self):
        value = slider_to_value(
            self.slider.value(), self.spec['min'], self.spec['max'],
            self.spec.get('log_scale', False))
        self.valueEdited.emit(self.spec['name'], value)

    def _on_spin_edited(self):
        if self._suppress:
            return
        value = self.spin.value()
        self._suppress = True
        self.slider.setValue(value_to_slider(
            value, self.spec['min'], self.spec['max'], self.spec.get('log_scale', False)))
        self._suppress = False
        self.valueEdited.emit(self.spec['name'], value)


class CameraTab(QWidget):
    def __init__(self, node_name, camera_name, bridge, parent=None):
        super().__init__(parent)
        self.node_name = node_name
        self.camera_name = camera_name
        self.bridge = bridge
        self.rows = {}
        self._latest_image = None
        self._roi_ratio = 0.0

        outer = QHBoxLayout(self)

        left_layout = QVBoxLayout()
        groups = {}
        for spec in PARAM_SPECS:
            group_name = spec['group']
            if group_name not in groups:
                box = QGroupBox(group_name)
                box.setLayout(QFormLayout())
                groups[group_name] = box
                left_layout.addWidget(box)
            row = ParamRow(spec)
            row.valueEdited.connect(self._on_value_edited)
            row.previewChanged.connect(self._on_preview_changed)
            groups[group_name].layout().addRow(spec['label'], row)
            self.rows[spec['name']] = row

        refresh_btn = QPushButton('새로고침')
        refresh_btn.clicked.connect(self.refresh)
        left_layout.addWidget(refresh_btn)
        left_layout.addStretch(1)

        left_widget = QWidget()
        left_widget.setLayout(left_layout)

        self.preview = QLabel('이미지 대기 중...')
        self.preview.setMinimumSize(480, 360)
        self.preview.setAlignment(Qt.AlignCenter)
        self.preview.setStyleSheet('background-color: #202020; color: #aaaaaa;')

        outer.addWidget(left_widget, 2)
        outer.addWidget(self.preview, 3)

        self.bridge.params_fetched.connect(self._on_params_fetched)
        self.bridge.image_received.connect(self._on_image_received)
        self.bridge.subscribe_image(camera_name)

        self.refresh()

    def refresh(self):
        self.bridge.fetch_parameters(self.node_name, PARAM_SPECS)

    def _on_params_fetched(self, node_name, values):
        if node_name != self.node_name:
            return
        for name, value in values.items():
            if name in self.rows:
                self.rows[name].set_value(value)
        if 'ae_roi_top_ratio' in values:
            self._roi_ratio = values['ae_roi_top_ratio']
            self._redraw_preview()

    def _on_value_edited(self, name, value):
        spec = PARAM_SPEC_BY_NAME[name]
        if spec.get('readonly'):
            return
        self.bridge.set_parameter(self.node_name, name, spec['type'], value)
        if name == 'ae_roi_top_ratio':
            self._roi_ratio = value
            self._redraw_preview()

    def _on_preview_changed(self, name, value):
        if name == 'ae_roi_top_ratio':
            self._roi_ratio = value
            self._redraw_preview()

    def _on_image_received(self, camera_name, qimg):
        if camera_name != self.camera_name:
            return
        self._latest_image = qimg
        self._redraw_preview()

    def _redraw_preview(self):
        if self._latest_image is None:
            return
        target_width = self.preview.width() if self.preview.width() > 0 else 480
        pixmap = QPixmap.fromImage(self._latest_image).scaledToWidth(
            target_width, Qt.SmoothTransformation)

        painter = QPainter(pixmap)
        w, h = pixmap.width(), pixmap.height()
        boundary_y = int(h * self._roi_ratio)
        if boundary_y > 0:
            painter.fillRect(0, 0, w, boundary_y, QColor(0, 0, 0, 120))
        pen = QPen(QColor(255, 60, 60))
        pen.setWidth(2)
        painter.setPen(pen)
        painter.drawLine(0, boundary_y, w, boundary_y)
        painter.end()

        self.preview.setPixmap(pixmap)


class MainWindow(QMainWindow):
    def __init__(self, bridge, cameras):
        super().__init__()
        self.setWindowTitle('hik_camera 노출 / 게인 / ROI 패널')
        self.bridge = bridge

        tabs = QTabWidget()
        for node_name, camera_name in cameras:
            tabs.addTab(CameraTab(node_name, camera_name, bridge), camera_name)
        self.setCentralWidget(tabs)

        self.setStatusBar(QStatusBar())
        bridge.param_set_result.connect(self._on_param_set_result)

        self.resize(1280, 720)

    def _on_param_set_result(self, node_name, name, ok, reason):
        if ok:
            self.statusBar().showMessage(f'[{node_name}] {name} 적용됨', 2000)
        else:
            self.statusBar().showMessage(f'[{node_name}] {name} 실패: {reason}', 6000)


def main(args=None):
    rclpy.init(args=args)

    app = QApplication(sys.argv)
    bridge = RosBridge()
    window = MainWindow(bridge, DEFAULT_CAMERAS)
    window.show()

    exit_code = app.exec_()
    bridge.shutdown()
    sys.exit(exit_code)


if __name__ == '__main__':
    main()
