# Copyright (c) 2026 동일비전(Dongil Vision Korea). All Rights Reserved.
"""
╔══════════════════════════════════════════════════════════════════════════════╗
║  ALASKA v2.4 - Task Signal System                                           ║
║  SmLib 전면 도입: SmSignalRegistry, SmKernelEvent/mp.Event, SmQueue         ║
╠══════════════════════════════════════════════════════════════════════════════╣
║  Version : 2.4.0                                                            ║
║  Date    : 2026-02-28                                                       ║
╠══════════════════════════════════════════════════════════════════════════════╣
║  Changes (v2.4.0):                                                          ║
║    - Manager.dict 구독 조회 → SmSignalRegistry (공유 메모리 비트맵)         ║
║    - spin-wait/sleep 대기 → SmKernelEvent(Win32) / mp.Event(Linux)         ║
║    - Process 모드 큐: SmQueue (SharedMemory IPC Queue)                      ║
║    - 통계/디버그 기능 on/off 토글 지원                                      ║
║    - 디버그 로깅 전체 제거                                                  ║
╚══════════════════════════════════════════════════════════════════════════════╝
"""

from __future__ import annotations
from typing import Any, Optional, TYPE_CHECKING, Union
import threading
import queue
import time
import sys
import os
import pickle
import traceback as _traceback  # ★ F23: top-level import

from ._comm_utils import _fmt_debug_call, _sanitize_id  # ★ Phase 1: 공통 유틸
from ._signal_core import (  # ★ Phase 8: 파일 분리
    PayloadTooLargeError, QueueFullError, QoS, Signal, PriorityScheduler,
    _SmSignalQueue, _QueueCompat, _sig_encode,
    _IS_WIN32, _STOP_TIMEOUT, _SIGNAL_QUEUE_SIZE,
    _PRIORITY_LEVELS, _QUOTAS, _NOTIFY_WAIT_TIMEOUT, _BP_RETRIES,
)

if TYPE_CHECKING:
    from multiprocessing.managers import SyncManager


# ──────────────────────────────────────────────────────────────────────────────
# SubscriberCache — 시그널 구독자 캐시 (SmSignalRegistry 연동)
# ──────────────────────────────────────────────────────────────────────────────

class SubscriberCache:
    """시그널 구독자 캐시 (SmSignalRegistry 연동)

    Attributes (핫패스 직접 접근):
        q_cache:       tid → queues 매핑 캐시
        reg_cache:     sig_name → [tid, ...] Manager.dict 폴백 캐시
        sub_ver_cache: sig_name → [tid, ...] SmSignalRegistry 버전 캐시
        ver_snap:      SmSignalRegistry.version 스냅샷
    """
    __slots__ = ('q_cache', 'reg_cache', 'sub_ver_cache', 'ver_snap',
                 '_cnt', '_ts')

    def __init__(self):
        self.q_cache = {}
        self.reg_cache = {}
        self.sub_ver_cache = {}
        self.ver_snap = -1
        self._cnt = 0
        self._ts = 0.0

    def invalidate(self, task_id: str = None):
        """캐시 무효화. task_id 지정 시 해당 큐만, None이면 전체."""
        if task_id is not None:
            self.q_cache.pop(task_id, None)
        else:
            self.q_cache.clear()
        self.reg_cache.clear()
        self.sub_ver_cache.clear()

    def invalidate_signal(self, sig_name: str):
        """특정 시그널의 구독자 캐시 무효화."""
        self.reg_cache.pop(sig_name, None)

    def periodic_check(self, stats_local: dict, stats_enabled, trace_enabled, flush_fn):
        """★ F22: count 기반 캐시 갱신 (time.time() syscall 제거)
        ~256 emit마다 시간 체크 → 고빈도 emit에서 -0.3μs/call"""
        self._cnt += 1
        if self._cnt < 256:
            return
        self._cnt = 0
        ts = time.time()
        if ts - self._ts > 5.0:
            self._ts = ts
            try:
                se = stats_enabled
                stats_local['__enabled'] = se.value if hasattr(se, 'value') else se
            except Exception:
                stats_local['__enabled'] = False
            if stats_local.get('__enabled', False):
                try:
                    te = trace_enabled
                    return te.value if hasattr(te, 'value') else te
                except Exception:
                    return False
        return None

    def reset(self):
        """서브프로세스 복원 시 캐시 초기화."""
        self.sub_ver_cache = {}
        self.ver_snap = -1
        self._cnt = 0
        self._ts = 0.0


# ──────────────────────────────────────────────────────────────────────────────
# SignalBroker - Core Registry (SmSignalRegistry + SmKernelEvent/mp.Event)
# ──────────────────────────────────────────────────────────────────────────────
class SignalBroker:
    __slots__ = ('_mode', '_mgr', '_queues', '_queues_local', '_registry', '_stats', '_stats_enabled',
                 '_trace_enabled', '_trace_buffer', '_trace_queue', '_heartbeats', '_write_lock',
                 '_stats_local', '_trace_local',
                 '_cache',  # ★ Phase 7: SubscriberCache (6개 캐시 필드 통합)
                 # v2.4 SmLib
                 '_sm_registry', '_sm_registry_name',
                 '_notify_events', '_notify_cache',
                 '_notify_event_names',
                 # v2.6 CPU 절감
                 '_sleeping_flags')

    def __init__(self, mgr: Optional[Any] = None):
        self._mode = 'process' if mgr else 'thread'
        self._mgr = mgr
        self._queues_local = {}
        if mgr:
            self._queues = mgr.dict()
            self._registry = mgr.dict()
            self._stats = mgr.dict()
            self._stats_enabled = True
            self._trace_enabled = False
            self._trace_buffer = mgr.list()
            self._trace_queue = None
            self._heartbeats = mgr.dict()
        else:
            self._queues, self._registry, self._stats = {}, {}, {}
            self._stats_enabled, self._trace_enabled = True, False
            self._trace_buffer, self._heartbeats = [], {}
            self._trace_queue = None
        self._write_lock = threading.Lock()
        self._stats_local = {}
        self._trace_local = False
        self._cache = SubscriberCache()  # ★ Phase 7: 6개 캐시 필드 통합
        # v2.4 SmLib (TaskManager.start_all에서 주입)
        self._sm_registry = None
        self._sm_registry_name = None
        self._notify_events = {}   # tid → SmKernelEvent 또는 mp.Event
        self._notify_cache = {}    # tid → event (로컬 캐시)
        self._notify_event_names = {}  # tid → event name (크로스프로세스 재연결용)
        self._sleeping_flags = {}  # v2.6: tid → SmValue('B') (is_sleeping 플래그)

    def __getstate__(self):
        """Pickle: 로컬 자원 배제, _q_cache 보존"""
        state = {k: getattr(self, k) for k in self.__slots__ if hasattr(self, k)}
        for k in ('_write_lock', '_mgr', '_queues_local',
                   '_sm_registry', '_notify_events', '_notify_cache'):
            state.pop(k, None)
        # _q_cache는 보존 (서브프로세스에서 Manager.dict 접근 회피)
        return state

    def __setstate__(self, state):
        """서브프로세스 복원"""
        for k, v in state.items():
            setattr(self, k, v)
        self._write_lock = threading.Lock()
        self._mgr = None
        self._queues_local = {}
        self._stats_local = {}
        self._trace_local = False
        self._cache.reset()  # ★ Phase 7: SubscriberCache 초기화
        # SmSignalRegistry는 TaskRuntime.run()에서 재연결
        self._sm_registry = None
        self._notify_events = {}
        self._notify_cache = {}

    def _get_queues(self, task_id: str):
        """thread(local) + process(Manager) 양쪽에서 큐 검색"""
        qs = self._queues_local.get(task_id)
        if qs is not None:
            return qs
        return self._queues.get(task_id)

    def _get_queues_cached(self, task_id: str):
        """큐 캐시에서 조회, 미스 시 조회 후 캐시"""
        qs = self._cache.q_cache.get(task_id)
        if qs is None:
            qs = self._get_queues(task_id)
            if qs:
                self._cache.q_cache[task_id] = qs
        return qs

    def _notify_task(self, task_id: str):
        """시그널 도착 알림 — 수신 스레드의 커널 대기를 깨움
        v2.6: is_sleeping 체크 (메모리 읽기 ~50ns, 불필요한 syscall 제거)
        """
        # ★ is_sleeping 체크: 이미 깨어있으면 syscall 불필요
        sf = self._sleeping_flags.get(task_id)
        if sf is not None:
            try:
                if sf.value == 0:
                    return  # 디스패처 활성 중 → set() 불필요
            except Exception:
                pass
        ev = self._notify_cache.get(task_id)
        if ev is None:
            ev = self._notify_events.get(task_id)
            if ev is None:
                # v2.5: 자식 프로세스에서 SmKernelEvent 이름으로 lazy re-open
                name = self._notify_event_names.get(task_id)
                if name and _IS_WIN32:
                    try:
                        from ..sm_infra import SmKernelEvent
                        ev = SmKernelEvent(name, create=False, manual_reset=True)
                    except Exception:
                        ev = None
            if ev is not None:
                self._notify_cache[task_id] = ev
        if ev is not None:
            try:
                ev.set()
            except Exception:
                pass

    # --- Mandatory Interface Stubs ---
    def start_relay(self): pass
    def stop_relay(self): pass
    def register(self, tid, mode='thread'): return self.register_inbox(tid, mode)

    def register_inbox(self, task_id: str, mode: str = 'thread') -> Any:
        with self._write_lock:
            if task_id in self._queues_local:
                return self._queues_local[task_id]
            try:
                existing = self._queues.get(task_id)
                if existing is not None:
                    return existing
            except Exception:
                pass
            if not self._mgr:
                qs = [queue.Queue(maxsize=_SIGNAL_QUEUE_SIZE) for _ in range(_PRIORITY_LEVELS)]
                self._queues_local[task_id] = _QueueCompat(qs)
                return self._queues_local[task_id]
            else:
                # v2.6: SmRingBuffer IPC + Per-Priority Mutex (경합 제거)
                _pid = os.getpid()
                _sid = _sanitize_id(task_id)
                qs = []
                for p in range(_PRIORITY_LEVELS):
                    name = f"alsk{_pid}_{_sid}_sq{p}"
                    lock_name = f"alsk{_pid}_{_sid}_mtx{p}"  # 우선순위별 Mutex 분리
                    qs.append(_SmSignalQueue(name, size=65536, create=True, lock_name=lock_name))
                qt = _QueueCompat(qs)
                self._queues[task_id] = qt
                self._cache.q_cache[task_id] = qt
                return qt

    def _periodic_cache_refresh(self):
        """★ F22: count 기반 캐시 갱신 → SubscriberCache 위임"""
        result = self._cache.periodic_check(
            self._stats_local, self._stats_enabled, self._trace_enabled, self._flush_stats
        )
        if result is not None:
            self._trace_local = result
            self._flush_stats()

    def _flush_stats(self):
        """로컬 통계 → Manager 플러시"""
        local = self._stats_local
        if not local: return
        try:
            for name, lc in local.items():
                if name.startswith('__'): continue
                curr = self._stats.get(name, {'cnt': 0, 'last': 0})
                self._stats[name] = {'cnt': curr['cnt'] + lc['cnt'], 'last': lc['last']}
            # __enabled 키만 남기고 정리
            en = local.get('__enabled', False)
            local.clear()
            local['__enabled'] = en
        except Exception:
            pass

    def report_trace(self, name, source, data, targets=None):
        if not self._stats_local.get('__enabled', False):
            return
        try:
            ts = time.time()
            lc = self._stats_local.get(name)
            if lc:
                lc['cnt'] += 1; lc['last'] = ts
            else:
                self._stats_local[name] = {'cnt': 1, 'last': ts}
            if not self._trace_local:
                return
            item = {'ts': ts, 'signal': name, 'source': source, 'data_preview': str(data)[:100]}
            if targets:
                item['targets'] = list(targets)
            self._trace_buffer.append(item)
            if len(self._trace_buffer) > 500:
                try:
                    del self._trace_buffer[:100]
                except Exception:
                    pass
        except Exception:
            pass

    def report_violation(self, sig_name, task_id, elapsed, qos):
        """데드라인 위반 보고"""
        deadline = qos.get('deadline', 0)
        action = qos.get('on_violation', 'LOG')
        msg = f"OVERRUN: {elapsed*1000:.2f}ms (Deadline: {deadline*1000:.2f}ms, P{qos.get('priority')})"
        if action == 'HALT':
            print(f"[CRITICAL] {task_id} -> {sig_name}: {msg} -> HALT")
            self.emit(Signal(name='_system.halt', source=task_id,
                           data={'reason': msg, 'signal': sig_name}, qos=QoS.CRITICAL))
        elif action == 'LOG':
            print(f"[QoS Violation] {task_id} -> {sig_name}: {msg}")
        self.report_trace(f"VIOLATION::{sig_name}", task_id, msg)

    def emit(self, signal: Signal) -> bool:
        """시그널 발행 — SmSignalRegistry 비트맵 조회 (Manager IPC 0회)"""
        try:
            if signal.name == "_rmi.trace":
                self.report_trace(signal.data['name'], signal.source, signal.data['data'],
                                  targets=signal.data.get('targets'))
                return True

            self._periodic_cache_refresh()

            sig_name = signal.name
            _c = self._cache  # C2: 로컬 캐시

            # ★ SmSignalRegistry에서 구독자 조회 — version 캐시 (v2.6)
            sm_reg = self._sm_registry
            if sm_reg is not None:
                _ver = sm_reg.get_version()
                if _ver != _c.ver_snap:
                    _c.sub_ver_cache.clear()
                    _c.ver_snap = _ver
                subs = _c.sub_ver_cache.get(sig_name)
                if subs is None:
                    subs = sm_reg.get_subscribers(sig_name)
                    _c.sub_ver_cache[sig_name] = subs
            else:
                # Fallback: 로컬 캐시 → Manager.dict
                subs = _c.reg_cache.get(sig_name)
                if subs is None:
                    subs = list(self._registry.get(sig_name, []))
                    if subs:
                        _c.reg_cache[sig_name] = subs

            if self._stats_local.get('__enabled', False):
                self.report_trace(sig_name, signal.source, signal.data, targets=subs)

            if not subs:
                return False

            q_idx = signal.qos.get('priority', 3) - 1  # C3: QoS 1-5 보장, clamp 불필요
            payload = {**signal.__dict__, 'ts': signal.timestamp or time.time()}  # C4: 기존 ts 재사용

            # ★ M-03: Broadcast Pre-encode — struct+pickle 직렬화 N→1회 (process 모드)
            _raw = _sig_encode(payload) if self._mode == 'process' else None

            _all_ok = True
            _failed_tids = []  # M-09: C5 backpressure 추적
            for tid in subs:
                try:
                    qs = self._get_queues_cached(tid)
                    if qs:
                        # ★ Back Pressure: bounded retry (무손실 보장)
                        _delivered = False
                        _q = qs[q_idx]
                        for _bp_retry in range(_BP_RETRIES):
                            try:
                                if _raw is not None:
                                    _q.write_raw(_raw)
                                else:
                                    _q.put_nowait(payload)
                                _delivered = True
                                break
                            except queue.Full:
                                time.sleep(0)  # yield → 소비자 drain 기회
                            except Exception:
                                break  # 복구 불가능한 에러 → retry 중단
                        if not _delivered:
                            _all_ok = False
                            _failed_tids.append(tid)  # M-09
                            continue  # ★ 나머지 subscriber에게 계속 전달
                        # ★ 수신 스레드 커널 대기 깨움
                        self._notify_task(tid)
                except Exception:
                    _all_ok = False
            # M-09: C5 Backpressure — 큐 포화 시 예외 (손실보다 예외)
            if _failed_tids:
                raise QueueFullError(signal.name, _failed_tids)
            return _all_ok
        except QueueFullError:
            raise  # M-09: backpressure 예외는 호출자에게 전파
        except Exception as _emit_err:
            import warnings as _w
            _w.warn(f"[SignalBroker] emit('{signal.name}') error: {_emit_err}", RuntimeWarning, stacklevel=2)
            return False

    def emit_batch(self, signals: list) -> bool:
        """배치 시그널 발행 — N건을 1 페이로드(list)로 묶어 subscriber당 1회 write.

        수신 측 _dispatch()는 list 타입을 감지하여 각 아이템을 개별 dispatch.
        pickle 1회 + SmRingBuffer write 1회 + notify 1회로 burst 성능 개선.
        """
        if not signals:
            return True
        try:
            self._periodic_cache_refresh()

            # 시그널별 그룹화 (동일 이름 → 동일 구독자)
            groups: dict[str, list] = {}
            _ts = time.time()
            for sig in signals:
                _name = sig.name
                _g = groups.get(_name)
                if _g is None:
                    _g = []
                    groups[_name] = _g
                _g.append({**sig.__dict__, 'ts': _ts})

            sm_reg = self._sm_registry
            _c = self._cache  # C2: 로컬 캐시
            _all_ok = True

            # C5: version 체크 1회 (루프 밖)
            if sm_reg is not None:
                _ver = sm_reg.get_version()
                if _ver != _c.ver_snap:
                    _c.sub_ver_cache.clear()
                    _c.ver_snap = _ver

            for sig_name, payloads in groups.items():
                # 구독자 조회
                if sm_reg is not None:
                    subs = _c.sub_ver_cache.get(sig_name)
                    if subs is None:
                        subs = sm_reg.get_subscribers(sig_name)
                        _c.sub_ver_cache[sig_name] = subs
                else:
                    subs = _c.reg_cache.get(sig_name)
                    if subs is None:
                        subs = list(self._registry.get(sig_name, []))
                        if subs:
                            _c.reg_cache[sig_name] = subs

                if not subs:
                    continue

                q_idx = payloads[0].get('qos', {}).get('priority', 3) - 1  # C3

                # ★ Batch pre-pickle: N건 → 1회 pickle (PM-18: 43x speedup)
                _raw = pickle.dumps(payloads, protocol=pickle.HIGHEST_PROTOCOL) if self._mode == 'process' else None

                _failed_tids = []  # M-09
                for tid in subs:
                    try:
                        qs = self._get_queues_cached(tid)
                        if qs:
                            _delivered = False
                            _q = qs[q_idx]
                            for _bp_retry in range(_BP_RETRIES):
                                try:
                                    if _raw is not None:
                                        _q.write_raw(_raw)
                                    else:
                                        _q.put_nowait(payloads)
                                    _delivered = True
                                    break
                                except queue.Full:
                                    time.sleep(0)
                                except Exception:
                                    break
                            if not _delivered:
                                _all_ok = False
                                _failed_tids.append(tid)  # M-09
                                continue
                            self._notify_task(tid)
                    except Exception:
                        _all_ok = False
                # M-09: C5 Backpressure
                if _failed_tids:
                    raise QueueFullError(sig_name, _failed_tids)
            return _all_ok
        except QueueFullError:
            raise  # M-09: backpressure 예외는 호출자에게 전파
        except Exception as _e:
            import warnings as _w
            _w.warn(f"[SignalBroker] emit_batch() error: {_e}", RuntimeWarning, stacklevel=2)
            return False

    @property
    def trace_enabled(self):
        try:
            te = self._trace_enabled
            return te.value if hasattr(te, 'value') else te
        except Exception:
            return False

    def get_trace(self, limit=100):
        try: return list(self._trace_buffer)[-limit:]
        except Exception: return []

    def clear_trace(self):
        try: del self._trace_buffer[:]
        except Exception: pass

    def get_stats(self):
        self._flush_stats()
        return {'enabled': self._stats_local.get('__enabled', True), 'signals': dict(self._stats)}

    def clear_stats(self):
        try: self._stats.clear()
        except Exception: pass
        return time.time()

    # --- v2.0 하위 호환 API ---
    def get_subscribers(self, sig_name: str) -> list:
        sm_reg = self._sm_registry
        if sm_reg is not None:
            return sm_reg.get_subscribers(sig_name)
        try: return list(self._registry.get(sig_name, []))
        except Exception: return []

    def emit_to(self, task_id: str, signal: Signal):
        try:
            q_idx = signal.qos.get('priority', 3) - 1  # C3/C12
            payload = {**signal.__dict__, 'ts': signal.timestamp or time.time()}  # C4/C12
            qs = self._get_queues_cached(task_id)
            if qs:
                qs[q_idx].put_nowait(payload)
                self._notify_task(task_id)
        except Exception: pass

    def emit_fast(self, sig_name: str, source: str, data: Any = None, **kw):
        sig = Signal(name=sig_name, source=source, data=data, qos=QoS.NORMAL)
        if '_seq' in kw: sig._seq = kw['_seq']
        if '_depth' in kw: sig._depth = kw['_depth']
        return self.emit(sig)

    def _distribute_signal(self, sig_name: str, source: str, data: Any = None, **kw):
        return self.emit_fast(sig_name, source, data, **kw)

    def subscribe(self, sig, tid):
        try:
            # 설계 9.2: SmSignalRegistry 우선 (할당 실패 시 False 반환)
            success = False
            sm_reg = self._sm_registry
            if sm_reg is not None:
                success = sm_reg.subscribe(sig, tid)
            
            # 설계 10.4: Manager.dict 하위 호환 및 폴백 유지
            with self._write_lock:
                tids = list(self._registry.get(sig, []))
                if tid not in tids:
                    tids.append(tid)
                    self._registry[sig] = tids
                self._cache.reg_cache.pop(sig, None)
        except Exception as e:
            print(f"[SignalBroker] Subscribe error: {e}")

    def unsubscribe(self, sig, tid):
        try:
            sm_reg = self._sm_registry
            if sm_reg is not None:
                sm_reg.unsubscribe(sig, tid)
            with self._write_lock:
                tids = list(self._registry.get(sig, []))
                if tid in tids:
                    tids.remove(tid)
                    self._registry[sig] = tids
                self._cache.reg_cache.pop(sig, None)
        except Exception: pass

    def unregister(self, task_id: str):
        try:
            with self._write_lock:
                try:
                    qs = self._queues.get(task_id)
                    if qs:
                        for sq in qs:
                            try: sq.close()
                            except Exception: pass
                    del self._queues[task_id]
                except (KeyError, Exception): pass
                try: del self._queues_local[task_id]
                except (KeyError, Exception): pass
                self._cache.q_cache.pop(task_id, None)
                # ★ SmSignalRegistry 비트맵 해제 (v2.6 fix: 좀비 구독 방지)
                sm_reg = self._sm_registry
                for sig in list(self._registry.keys()):
                    tids = list(self._registry.get(sig, []))
                    if task_id in tids:
                        if sm_reg is not None:
                            try: sm_reg.unsubscribe(sig, task_id)
                            except Exception: pass
                        tids.remove(task_id)
                        if tids: self._registry[sig] = tids
                        else: del self._registry[sig]
                # ★ 캐시 무효화 (v2.6 fix)
                self._cache.reg_cache.clear()
                self._cache.sub_ver_cache.clear()
                try: del self._heartbeats[task_id]
                except (KeyError, Exception): pass
                # notify event 정리
                ev = self._notify_events.pop(task_id, None)
                self._notify_cache.pop(task_id, None)
                if ev and hasattr(ev, 'close'):
                    try: ev.close()
                    except Exception: pass
        except Exception: pass

    @property
    def stats_enabled(self):
        try:
            se = self._stats_enabled
            return se.value if hasattr(se, 'value') else se
        except Exception: return True

    @stats_enabled.setter
    def stats_enabled(self, value):
        try:
            if hasattr(self._stats_enabled, 'value'):
                self._stats_enabled.value = value
            else:
                self._stats_enabled = value
        except Exception: pass

    def set_trace(self, enabled: bool):
        try:
            if hasattr(self._trace_enabled, 'value'):
                self._trace_enabled.value = enabled
            else:
                self._trace_enabled = enabled
            if not enabled:
                self.clear_trace()
        except Exception: pass


# ──────────────────────────────────────────────────────────────────────────────
# SignalClient - P2P Agent (SmKernelEvent/mp.Event 기반 수신)
# ──────────────────────────────────────────────────────────────────────────────
class SignalClient:
    __slots__ = ('_broker', '_task_id', '_queues', '_handlers', '_running', '_thread',
                 '_depth_ctx', '_debug', '_mode', '_emit_seqs', '_recv_seqs',
                 '_scheduler', '_job', '_notify_event',
                 '_cycle_time', '_qos_enabled')

    def __init__(self, broker: SignalBroker, task_id: str, mode: str = 'thread',
                 debug: bool = False, notify_event=None, qos_enabled: bool = False):
        self._broker = broker
        self._task_id = task_id
        self._mode = mode
        self._queues = broker.register_inbox(task_id, mode=mode)
        self._handlers = {}
        self._running = False
        self._thread = None
        self._depth_ctx = threading.local()
        self._debug = debug
        self._emit_seqs = {}
        self._recv_seqs = {}
        self._scheduler = PriorityScheduler()
        self._job = None
        # ★ 커널 이벤트 (SmKernelEvent 또는 mp.Event)
        self._notify_event = notify_event
        self._cycle_time = 0.0  # v2.6: 사이클 시작 시간 (deadline 체크용)
        self._qos_enabled = qos_enabled  # v2.6: QoS 데드라인 체크 (기본 off)

    @property
    def _queue(self):
        """v2.0 호환: NORMAL 우선순위 큐 반환"""
        return self._queues[2]

    def start(self):
        """수신 스레드 시작 (InvokeDispenser 미사용 시 호환용)."""
        self._running = True
        self._thread = threading.Thread(target=self._adaptive_recv_loop, daemon=True)
        self._thread.start()

    def stop(self):
        self._running = False
        ev = self._notify_event
        if ev is not None:
            try: ev.set()
            except Exception: pass
        if self._thread:
            self._thread.join(timeout=_STOP_TIMEOUT)

    def _adaptive_recv_loop(self):
        """수신 루프 — start() 호출 시 사용 (InvokeDispenser 미사용 환경 호환)."""
        qs = self._queues
        scheduler = self._scheduler
        notify_event = self._notify_event
        last_hb = 0

        while self._running:
            now_perf = time.perf_counter()
            any_work = False

            if now_perf - last_hb > 1.0:
                try: self._broker._heartbeats[self._task_id] = time.time()
                except Exception: pass
                last_hb = now_perf

            self._cycle_time = time.time()
            scheduler.new_cycle()
            for p in range(_PRIORITY_LEVELS):
                quota = scheduler.CYCLE_QUOTAS[p]
                for _ in range(quota):
                    try:
                        data = qs[p].get_nowait()
                        self._dispatch(data)
                        scheduler.consume(p)
                        any_work = True
                    except (queue.Empty, IndexError, Exception):
                        break

            if not any_work:
                if notify_event is not None:
                    try: notify_event.clear()
                    except Exception: pass
                still_idle = True
                for p in range(_PRIORITY_LEVELS):
                    if not qs[p].empty():
                        still_idle = False; break
                if still_idle:
                    if notify_event is not None:
                        try: notify_event.wait(timeout=_NOTIFY_WAIT_TIMEOUT)
                        except Exception: time.sleep(0.001)
                    else:
                        time.sleep(0.001)
            else:
                if notify_event is not None:
                    try: notify_event.clear()
                    except Exception: pass

    def _dispatch(self, data):
        # ★ M-02: 배치 수신 — list 타입이면 각 아이템 개별 dispatch
        if type(data) is list:  # C9: identity 비교
            for item in data:
                self._dispatch_single(item)
            return
        self._dispatch_single(data)

    def _dispatch_single(self, data):
        sig_name = data['name']
        # Nowait RMI 처리 (Signal P4 큐 경유)
        if sig_name == '_rmi.nowait':
            self._dispatch_rmi_nowait(data)
            return
        # C10: 핸들러 조기 체크 — 없으면 seq 트래킹만 후 return
        lst = self._handlers.get(sig_name)
        qos = data.get('qos', {})
        deadline = qos.get('deadline')
        # ★ v2.6: cycle-time 사용 (time.time() N→1/cycle), QoS off 시 스킵
        if deadline and self._qos_enabled:
            _elapsed = self._cycle_time - data.get('ts', 0)
            if _elapsed > deadline:
                self._broker.report_violation(sig_name, self._task_id, _elapsed, qos)
                return
        # v2.0 호환: seq 트래킹
        _seq = data.get('_seq', 0)
        if _seq > 0:
            _src = data.get('source', '')
            key = (_src, sig_name)
            last = self._recv_seqs.get(key, 0)
            if last > 0 and _seq > last + 1:
                gap = _seq - last - 1
                print(f"[Signal] {sig_name} from {_src}: {gap} missed (seq {last+1}~{_seq-1})")
            self._recv_seqs[key] = _seq
        if not lst:
            return
        _src = data.get('source', '')
        sig_obj = Signal(name=sig_name, source=_src, data=data.get('data'), qos=qos)
        for h in lst:
            if self._debug:
                _prefix = f"[{self._task_id:>12s}]  on  | "
                _suffix = f" from {_src}"
                _data = sig_obj.data
                if _data is not None:
                    print(_fmt_debug_call(_prefix, h.__name__, kwargs={'data': _data}, suffix=_suffix))
                else:
                    print(f"{_prefix}{h.__name__}(){_suffix}")
            try: h(sig_obj)
            except Exception as _h_err:
                print(f"[Signal] Handler '{h.__name__}' error for '{sig_name}':\n{_traceback.format_exc()}")

    def _dispatch_rmi_nowait(self, data):
        """Nowait RMI 디스패치 (Signal P4 큐 경유 수신)"""
        req = data.get('data', {})
        m = req.get('m')
        if not m:
            return
        job = self._job
        if job is None:
            return
        fn = getattr(job, m, None)
        if fn and callable(fn):
            try:
                a = req.get('a', ())
                kw = req.get('kw', {})
                fn(*a, **kw)
            except Exception as _nw_err:
                print(f"[RMI-nowait] {m}() error:\n{_traceback.format_exc()}")

    def emit(self, name: str, data: Any = None, qos: Union[str, dict] = None):
        seq = self._emit_seqs.get(name, 0) + 1
        self._emit_seqs[name] = seq
        sig = Signal(name=name, source=self._task_id, data=data, qos=QoS.from_input(qos), _seq=seq)
        # v2.6: 직접 emit (IO 스레드 위임 제거, CPU 절감)
        result = self._broker.emit(sig)
        if self._debug:
            # v2.6: SmSignalRegistry 활성 시 _sub_ver_cache, 아니면 _reg_cache
            _b = self._broker
            _c = _b._cache
            _subs = _c.sub_ver_cache.get(name) if _b._sm_registry is not None else _c.reg_cache.get(name)
            _nsub = len(_subs) if _subs else 0
            _prefix = f"[{self._task_id:>12s}] emit | "
            _suffix = f" {_nsub} subscribers"
            _func = f"{name}.emit"
            if data is not None:
                print(_fmt_debug_call(_prefix, _func, kwargs={'data': data}, suffix=_suffix))
            else:
                print(f"{_prefix}{_func}(){_suffix}")
        return result

    def emit_batch(self, name: str, data_list: list, qos: Union[str, dict] = None) -> bool:
        """배치 emit — 동일 시그널 N건을 1 페이로드로 묶어 전송.

        Args:
            name: 시그널 이름
            data_list: 데이터 리스트 (각 항목이 개별 Signal.data)
            qos: QoS 설정 (전체 배치에 동일 적용)
        Returns:
            전체 구독자 전달 성공 여부
        """
        _qos = QoS.from_input(qos)
        _src = self._task_id
        _seq_base = self._emit_seqs.get(name, 0)
        signals = []
        for i, data in enumerate(data_list):
            _seq = _seq_base + i + 1
            signals.append(Signal(name=name, source=_src, data=data, qos=_qos, _seq=_seq))
        self._emit_seqs[name] = _seq_base + len(data_list)
        return self._broker.emit_batch(signals)

    def emit_to(self, task_id: str, sig_name: str, data: Any = None):
        sig = Signal(name=sig_name, source=self._task_id, data=data, qos=QoS.NORMAL)
        self._broker.emit_to(task_id, sig)

    def on(self, name, handler):
        if name not in self._handlers: self._handlers[name] = []
        self._handlers[name].append(handler)
        self._broker.subscribe(name, self._task_id)

    def off(self, name, handler):
        lst = self._handlers.get(name)
        if lst and handler in lst:
            lst.remove(handler)
            if not lst:
                del self._handlers[name]
                self._broker.unsubscribe(name, self._task_id)

    def off_all(self): self._handlers.clear()


# ──────────────────────────────────────────────────────────────────────────────
# _SignalEmitter — Signal 체인 접근자 (self.signal.sensor.temp.emit/on/off)
# ──────────────────────────────────────────────────────────────────────────────
class _SignalEmitter:
    """Signal 체인 접근자 (self.signal.sensor.temp.emit/on/off)"""
    __slots__ = ('_client', '_path', '_cache')

    def __init__(self, client, path: str = ""):
        object.__setattr__(self, '_client', client)
        object.__setattr__(self, '_path', path)
        object.__setattr__(self, '_cache', {})

    def __getattr__(self, name: str) -> '_SignalEmitter':
        cache = self._cache
        emitter = cache.get(name)
        if emitter is None:
            new_path = f"{self._path}.{name}" if self._path else name
            emitter = _SignalEmitter(self._client, new_path)
            cache[name] = emitter
        return emitter

    def emit(self, *args, **kwargs):
        """Signal 발신 (v2.1: 직접 호출 및 체인 호출 모두 지원)
        - self.signal.emit("name", data)           <- 직접 호출
        - self.signal.name.emit(data)              <- 체인 호출
        - self.signal.name.emit(data, qos="LOW")   <- QoS 지정
        """
        if not self._client: return
        qos = kwargs.get('qos', None)

        # 인자 개수에 따른 처리
        if len(args) == 2:
            # 직접 호출: emit("name", data)
            name, data = args[0], args[1]
            self._client.emit(name, data, qos=qos)
        elif len(args) == 1:
            if self._path:
                # 체인 호출: name.emit(data)
                self._client.emit(self._path, args[0], qos=qos)
            else:
                # 직접 호출 (인자 1개): emit("name")
                self._client.emit(args[0], None, qos=qos)
        else:
            # 인자 없음: name.emit()
            if self._path:
                self._client.emit(self._path, None, qos=qos)

    def emit_batch(self, data_list, **kwargs):
        """배치 Signal 발신: self.signal.name.emit_batch([d1, d2, ...])"""
        if not self._client or not self._path:
            return False
        qos = kwargs.get('qos', None)
        return self._client.emit_batch(self._path, data_list, qos=qos)

    def on(self, handler):
        """Signal 구독: self.signal.job.a.on(handler)"""
        if self._client and self._path:
            self._client.on(self._path, handler)

    def off(self, handler=None):
        """Signal 구독 해제: self.signal.job.a.off(handler)"""
        if self._client and self._path:
            self._client.off(self._path, handler)
