# 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, Dict, List, Callable, Optional, Tuple, TYPE_CHECKING, Union
from dataclasses import dataclass, field
import threading
import queue
import time
import sys
import os
import pickle
import ctypes

from ..sm_infra.sm_ring_buffer import SmRingBuffer

if TYPE_CHECKING:
    from multiprocessing.managers import SyncManager

# ──────────────────────────────────────────────────────────────────────────────
# Platform Detection
# ──────────────────────────────────────────────────────────────────────────────
_IS_WIN32 = sys.platform == 'win32'

# ──────────────────────────────────────────────────────────────────────────────
# Constants & QoS Definitions
# ──────────────────────────────────────────────────────────────────────────────
_RECV_TIMEOUT = 0.002
_STOP_TIMEOUT = 1.0
_SIGNAL_QUEUE_SIZE = 1024
_MAX_PAYLOAD_SIZE = 2048
_MAX_SIGNAL_DEPTH = 5
_PRIORITY_LEVELS = 5
_QUOTAS = [20, 10, 5, 2, 1]
_NOTIFY_WAIT_TIMEOUT = 0.1  # 커널 이벤트 대기 시간 (초)
_BP_RETRIES = 50            # Back Pressure: 큐 full 시 재시도 횟수 (~5ms)

class PayloadTooLargeError(Exception): pass

class QoS:
    CRITICAL   = {"priority": 1, "deadline": 0.010, "on_violation": "HALT"}
    REALTIME   = {"priority": 2, "deadline": 0.030, "on_violation": "LOG"}
    NORMAL     = {"priority": 3, "deadline": 0.100, "on_violation": "LOG"}
    LOW        = {"priority": 4, "deadline": 0.500, "on_violation": "IGNORE"}
    BACKGROUND = {"priority": 5, "deadline": 2.000, "on_violation": "IGNORE"}

    @staticmethod
    def from_input(val: Any) -> dict:
        if isinstance(val, dict): return val
        if isinstance(val, str): return getattr(QoS, val, QoS.NORMAL)
        return QoS.NORMAL

class _WinMutex:
    """Win32 Named Mutex — Manager.Lock 대체 (직접 커널 호출, IPC 0회)"""
    __slots__ = ('_name', '_handle', '_k32')
    _MUTEX_ALL_ACCESS = 0x1F0001
    _INFINITE = 0xFFFFFFFF

    def __init__(self, name, create=True):
        self._name = name
        self._k32 = ctypes.windll.kernel32
        win_name = name if name.startswith("Global\\") else rf"Global\{name}"
        if create:
            self._handle = self._k32.CreateMutexW(None, False, win_name)
        else:
            self._handle = self._k32.OpenMutexW(self._MUTEX_ALL_ACCESS, False, win_name)
        if not self._handle:
            raise OSError(f"Mutex '{win_name}' failed: {ctypes.get_last_error()}")

    def acquire(self):
        self._k32.WaitForSingleObject(self._handle, self._INFINITE)

    def release(self):
        self._k32.ReleaseMutex(self._handle)

    def __enter__(self):
        self.acquire()
        return self

    def __exit__(self, *a):
        self.release()

    def close(self):
        if hasattr(self, '_handle') and self._handle:
            self._k32.CloseHandle(self._handle)
            self._handle = None

    def __del__(self):
        self.close()

    def __getstate__(self):
        return {'name': self._name}

    def __setstate__(self, state):
        self.__init__(state['name'], create=False)


class _SmSignalQueue:
    """SmRingBuffer + Named Mutex wrapper — queue.Queue 호환 Signal IPC.

    MPSC(다중생산자-단일소비자) 패턴:
      - write: Named Mutex 보호 (다중 emitter, 커널 직접 호출)
      - read: 단일 recv thread (Lock 불필요)
    """
    __slots__ = ('_rb', '_lock', '_name', '_size', '_lock_name')

    def __init__(self, name, size=65536, create=False, lock_name=None):
        self._name = name
        self._size = size
        self._lock_name = lock_name
        if lock_name and _IS_WIN32:
            try:
                self._lock = _WinMutex(lock_name, create=create)
            except OSError:
                self._lock = _WinMutex(lock_name, create=False)
        else:
            self._lock = None
        if create:
            try:
                self._rb = SmRingBuffer(name, size=size, create=True)
            except FileExistsError:
                self._rb = SmRingBuffer(name, size=size, create=False)
                self._rb._is_new = True
                self._rb.reset()
        else:
            self._rb = SmRingBuffer(name, size=size, create=False)

    def put_nowait(self, obj):
        data = pickle.dumps(obj, protocol=pickle.HIGHEST_PROTOCOL)
        lock = self._lock
        if lock:
            with lock:
                if not self._rb.write(data):
                    raise queue.Full()
        else:
            if not self._rb.write(data):
                raise queue.Full()

    def write_raw(self, data: bytes):
        """Pre-pickled 바이트 직접 쓰기 — Broadcast Pre-pickle용 (v2.6)"""
        lock = self._lock
        if lock:
            with lock:
                if not self._rb.write(data):
                    raise queue.Full()
        else:
            if not self._rb.write(data):
                raise queue.Full()

    def get_nowait(self):
        data = self._rb.read()
        if data is None:
            raise queue.Empty()
        return pickle.loads(data)

    def get(self, *a, **kw):
        return self.get_nowait()

    def empty(self):
        return self._rb._used() == 0

    def qsize(self):
        return self._rb._used()

    def close(self):
        if self._lock:
            try: self._lock.close()
            except Exception: pass
        self._rb.close()

    def reset(self):
        self._rb.reset()

    def __getstate__(self):
        return {'name': self._name, 'size': self._size, 'lock_name': self._lock_name}

    def __setstate__(self, state):
        self._name = state['name']
        self._size = state['size']
        self._lock_name = state.get('lock_name')
        self._rb = SmRingBuffer(self._name, self._size, create=False)
        if self._lock_name and _IS_WIN32:
            try:
                self._lock = _WinMutex(self._lock_name, create=False)
            except Exception:
                self._lock = None
        else:
            self._lock = None


class _QueueCompat(tuple):
    """v2.0 호환 래퍼: tuple[Queue] + 단일큐 API (NORMAL=P3으로 위임)"""
    def empty(self): return self[2].empty()
    def get(self, *a, **kw): return self[2].get(*a, **kw)
    def get_nowait(self): return self[2].get_nowait()
    def put(self, *a, **kw): return self[2].put(*a, **kw)
    def put_nowait(self, *a, **kw): return self[2].put_nowait(*a, **kw)
    def qsize(self): return self[2].qsize()

@dataclass
class Signal:
    name: str
    source: str
    data: Any = None
    qos: dict = field(default_factory=dict)
    _seq: int = 0
    _depth: int = 0
    signal_id: str = ""
    timestamp: float = 0.0

# ──────────────────────────────────────────────────────────────────────────────
# PriorityScheduler - Dual-Loop Shared Scheduler
# ──────────────────────────────────────────────────────────────────────────────
class PriorityScheduler:
    """듀얼 루프(RMI handler + Signal handler) 간 공유 우선순위 스케줄러.

    Weighted Fair Queuing + Starvation Guard 알고리즘:
      - 매 사이클 P1~P5 예산(budget) 리필
      - P1부터 순차 소비, 예산 소진 시 다음 우선순위로
      - N 사이클 연속 미처리 시 기아 방지 (강제 1회 처리)
    """
    __slots__ = ('_budgets', '_starvation', '_rmi_count', '_sig_count')

    CYCLE_QUOTAS = _QUOTAS                    # [20, 10, 5, 2, 1]
    STARVATION_LIMIT = 50

    def __init__(self):
        self._budgets = list(self.CYCLE_QUOTAS)
        self._starvation = [0] * _PRIORITY_LEVELS
        self._rmi_count = 0
        self._sig_count = 0

    def new_cycle(self):
        """매 폴링 사이클 시작 시 예산 리필."""
        self._budgets[:] = self.CYCLE_QUOTAS

    def next_priority(self) -> int:
        """다음 처리할 우선순위 반환 (P1=0 ~ P5=4)."""
        for p in range(_PRIORITY_LEVELS):
            if self._budgets[p] > 0:
                return p
            self._starvation[p] += 1
            if self._starvation[p] > self.STARVATION_LIMIT:
                self._starvation[p] = 0
                return p
        return 2  # fallback: NORMAL

    def consume(self, priority: int):
        """메시지 1건 처리 후 예산 차감."""
        if 0 <= priority < _PRIORITY_LEVELS:
            self._budgets[priority] = max(0, self._budgets[priority] - 1)
            self._starvation[priority] = 0
        self._sig_count += 1

    def account_rmi(self, qos_priority: int = 2):
        """RMI handler 처리 완료 보고 (통계용)."""
        self._rmi_count += 1

    @property
    def stats(self) -> dict:
        return {'rmi': self._rmi_count, 'signal': self._sig_count,
                'budgets': list(self._budgets)}


# ──────────────────────────────────────────────────────────────────────────────
# 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_ts',
                 '_q_cache', '_reg_cache',
                 '_sub_ver_cache', '_sub_ver_snap',
                 # 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_ts = 0.0
        self._q_cache = {}
        self._reg_cache = {}
        self._sub_ver_cache = {}   # v2.6: {sig_name: [tid, ...]} 로컬 구독자 캐시
        self._sub_ver_snap = -1    # v2.6: SmSignalRegistry.version 스냅샷
        # 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_ts = 0.0
        self._sub_ver_cache = {}
        self._sub_ver_snap = -1
        # 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 _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
                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 = task_id.replace('.', '_').replace('-', '_')
                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._q_cache[task_id] = qt
                return qt

    def _periodic_cache_refresh(self):
        """5초 주기 캐시 갱신"""
        ts = time.time()
        if ts - self._cache_ts > 5.0:
            self._cache_ts = ts
            try:
                se = self._stats_enabled
                self._stats_local['__enabled'] = se.value if hasattr(se, 'value') else se
            except Exception:
                self._stats_local['__enabled'] = False
            if self._stats_local.get('__enabled', False):
                try:
                    te = self._trace_enabled
                    self._trace_local = te.value if hasattr(te, 'value') else te
                except Exception:
                    self._trace_local = False
                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

            # ★ SmSignalRegistry에서 구독자 조회 — version 캐시 (v2.6)
            sm_reg = self._sm_registry
            if sm_reg is not None:
                _ver = sm_reg.get_version()
                if _ver == self._sub_ver_snap:
                    subs = self._sub_ver_cache.get(sig_name)
                    if subs is None:
                        subs = sm_reg.get_subscribers(sig_name)
                        self._sub_ver_cache[sig_name] = subs
                else:
                    self._sub_ver_cache.clear()
                    self._sub_ver_snap = _ver
                    subs = sm_reg.get_subscribers(sig_name)
                    self._sub_ver_cache[sig_name] = subs
            else:
                # Fallback: 로컬 캐시 → Manager.dict
                subs = self._reg_cache.get(sig_name)
                if subs is None:
                    subs = list(self._registry.get(sig_name, []))
                    if subs:
                        self._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 = max(0, min(signal.qos.get('priority', 3) - 1, _PRIORITY_LEVELS - 1))
            payload = {**signal.__dict__, 'ts': time.time()}

            # ★ v2.6: Broadcast Pre-pickle — 직렬화 N→1회 (process 모드)
            _raw = pickle.dumps(payload, protocol=pickle.HIGHEST_PROTOCOL) if self._mode == 'process' else None

            for tid in subs:
                try:
                    qs = self._q_cache.get(tid)
                    if qs is None:
                        qs = self._get_queues(tid)
                        if qs:
                            self._q_cache[tid] = qs
                    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, Exception):
                                time.sleep(0)  # yield → 소비자 drain 기회
                        if not _delivered:
                            return False  # ★ 큐 full → caller에게 재시도 요청
                        # ★ 수신 스레드 커널 대기 깨움
                        self._notify_task(tid)
                except Exception:
                    pass
            return True
        except Exception:
            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 = max(0, min(signal.qos.get('priority', 3) - 1, _PRIORITY_LEVELS - 1))
            payload = {**signal.__dict__, 'ts': time.time()}
            qs = self._q_cache.get(task_id)
            if qs is None:
                qs = self._get_queues(task_id)
                if qs: self._q_cache[task_id] = qs
            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._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._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._q_cache.pop(task_id, None)
                for sig in list(self._registry.keys()):
                    tids = list(self._registry.get(sig, []))
                    if task_id in tids:
                        tids.remove(task_id)
                        if tids: self._registry[sig] = tids
                        else: del self._registry[sig]
                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')

    def __init__(self, broker: SignalBroker, task_id: str, mode: str = 'thread',
                 debug: bool = False, notify_event=None):
        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 체크용)

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

    def start(self):
        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):
        """v2.5 Legacy — InvokeDispenser가 대체. start() 미호출 시 사용 안 됨."""
        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

            # 하트비트 갱신 (1초 주기)
            if now_perf - last_hb > 1.0:
                try: self._broker._heartbeats[self._task_id] = time.time()
                except Exception: pass
                last_hb = now_perf

            # ── RECV: 수신 큐 스캔 ──
            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

            # ── WAIT: 수신 없으면 커널 대기 (CPU 0%) ──
            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):
        sig_name = data['name']
        # Nowait RMI 처리 (Signal P4 큐 경유)
        if sig_name == '_rmi.nowait':
            self._dispatch_rmi_nowait(data)
            return
        qos = data.get('qos', {})
        deadline = qos.get('deadline')
        # ★ v2.6: cycle-time 사용 (time.time() N→1/cycle)
        if deadline:
            _ct = self._cycle_time
            _elapsed = _ct - 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
        lst = self._handlers.get(sig_name)
        if lst:
            sig_obj = Signal(name=sig_name, source=data.get('source', ''), data=data.get('data'), qos=qos)
            for h in lst:
                try: h(sig_obj)
                except Exception: pass

    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:
                pass

    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 절감)
        return self._broker.emit(sig)

    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()
