import tkinter as tk
from tkinter import simpledialog, messagebox, scrolledtext, ttk
import urllib.request
import urllib.error
import json
import ssl
import sys
import uuid
import os
import hashlib
import time
import socket
from typing import Optional, Dict, Any, List, Callable

class BrainUnreachableError(ConnectionError):
    """Raised when the Brain Host cannot be reached over the network (timeout, no route, refused)."""
    pass

class CertificateMismatchError(ssl.SSLError):
    """Raised when the Brain Host TLS certificate SHA256 does not match the pinned fingerprint."""
    def __init__(self, expected, actual):
        super().__init__(f"Certificate pinning mismatch! Expected {expected}, got {actual}")
        self.expected = expected
        self.actual = actual

CURRENT_VERSION = "0.1.0-alpha.69"
import shutil
import subprocess

try:
    from workbench import CreatorWorkbench, ProjectRegistry, ProjectSessionStore
except ImportError:
    try:
        from .workbench import CreatorWorkbench, ProjectRegistry, ProjectSessionStore
    except Exception:
        CreatorWorkbench = None
        ProjectRegistry = None
        ProjectSessionStore = None

try:
    from client_role_view import ClientRoleView
except ImportError:
    try:
        from .client_role_view import ClientRoleView
    except Exception:
        ClientRoleView = None

def configure_normal_work_window(
    win: tk.Toplevel,
    title: Optional[str] = None,
    geometry: Optional[str] = None,
    minsize: Optional[tuple] = None,
) -> tk.Toplevel:
    """
    Applies Digital Optimus Window Policy for NORMAL_WORK_WINDOW:
    - Freely minimizable, maximizable, restorable, and movable.
    - Not transient to parent (does not lock in z-order, allows minimize/maximize on Windows).
    - Non-modal (no grab_set; other windows remain fully interactive).
    - Not permanently topmost.
    - Resizable in both axes: win.resizable(True, True).
    """
    if title:
        win.title(title)
    if geometry:
        win.geometry(geometry)
    if minsize:
        win.minsize(*minsize)

    win.resizable(True, True)
    try:
        win.transient("")
    except Exception:
        pass
    try:
        win.grab_release()
    except Exception:
        pass
    try:
        win.attributes("-topmost", False)
    except Exception:
        pass

    return win

def get_config_dir():
    local_app_data = os.environ.get('LOCALAPPDATA', os.path.expanduser('~\\AppData\\Local'))
    cfg_dir = os.path.join(local_app_data, 'DigitalOptimus', 'user_data')
    os.makedirs(cfg_dir, exist_ok=True)
    return cfg_dir

def get_config_file():
    override = os.environ.get("DIGITAL_OPTIMUS_CONFIG_FILE")
    if override:
        return override
    return os.path.join(get_config_dir(), "config.json")

def get_device_token_dpapi_file(cfg_path=None):
    base_cfg = cfg_path or get_config_file()
    return os.path.join(os.path.dirname(os.path.abspath(base_cfg)), "device_token.dpapi")

import base64
import ctypes
import ctypes.wintypes

class _DATA_BLOB(ctypes.Structure):
    _fields_ = [
        ("cbData", ctypes.wintypes.DWORD),
        ("pbData", ctypes.POINTER(ctypes.c_char)),
    ]

def _dpapi_protect_bytes(raw_bytes: bytes) -> bytes:
    if not raw_bytes:
        return b""
    crypt32 = ctypes.windll.crypt32
    kernel32 = ctypes.windll.kernel32
    buf = ctypes.create_string_buffer(raw_bytes, len(raw_bytes))
    blob_in = _DATA_BLOB(len(raw_bytes), ctypes.cast(buf, ctypes.POINTER(ctypes.c_char)))
    blob_out = _DATA_BLOB()
    CRYPTPROTECT_UI_FORBIDDEN = 0x01
    ok = crypt32.CryptProtectData(
        ctypes.byref(blob_in),
        None,
        None,
        None,
        None,
        CRYPTPROTECT_UI_FORBIDDEN,
        ctypes.byref(blob_out),
    )
    if not ok:
        raise OSError("CryptProtectData failed")
    try:
        return ctypes.string_at(blob_out.pbData, blob_out.cbData)
    finally:
        kernel32.LocalFree(blob_out.pbData)

def _dpapi_unprotect_bytes(enc_bytes: bytes) -> bytes:
    if not enc_bytes:
        return b""
    crypt32 = ctypes.windll.crypt32
    kernel32 = ctypes.windll.kernel32
    buf = ctypes.create_string_buffer(enc_bytes, len(enc_bytes))
    blob_in = _DATA_BLOB(len(enc_bytes), ctypes.cast(buf, ctypes.POINTER(ctypes.c_char)))
    blob_out = _DATA_BLOB()
    CRYPTPROTECT_UI_FORBIDDEN = 0x01
    ok = crypt32.CryptUnprotectData(
        ctypes.byref(blob_in),
        None,
        None,
        None,
        None,
        CRYPTPROTECT_UI_FORBIDDEN,
        ctypes.byref(blob_out),
    )
    if not ok:
        raise OSError("CryptUnprotectData failed")
    try:
        return ctypes.string_at(blob_out.pbData, blob_out.cbData)
    finally:
        kernel32.LocalFree(blob_out.pbData)

CONFIG_FILE = get_config_file()

def get_canonical_app_dir():
    override = os.environ.get("DIGITAL_OPTIMUS_CANONICAL_APP_DIR")
    if override:
        return override
    local_app_data = os.environ.get('LOCALAPPDATA', os.path.expanduser('~\\AppData\\Local'))
    return os.path.join(local_app_data, 'Programs', 'Digital Optimus')

def get_canonical_exe_path():
    return os.path.join(get_canonical_app_dir(), 'DigitalOptimusClient.exe')

def get_desktop_dir():
    user_profile = os.environ.get('USERPROFILE', os.path.expanduser('~'))
    return os.path.join(user_profile, 'Desktop')

def get_desktop_shortcut_path():
    return os.path.join(get_desktop_dir(), "Digital Optimus.lnk")

def get_start_menu_programs_dir():
    app_data = os.environ.get('APPDATA', os.path.expanduser('~\\AppData\\Roaming'))
    return os.path.join(app_data, 'Microsoft', 'Windows', 'Start Menu', 'Programs')

def get_start_menu_shortcut_path():
    return os.path.join(get_start_menu_programs_dir(), "Digital Optimus.lnk")

def get_shortcut_info(shortcut_path):
    if not os.path.exists(shortcut_path):
        return None, None
    try:
        import win32com.client
        ws = win32com.client.Dispatch("WScript.Shell")
        sc = ws.CreateShortcut(os.path.abspath(shortcut_path))
        target = sc.TargetPath
        args = sc.Arguments or ""
        if target:
            return os.path.abspath(target), args.strip()
    except Exception:
        pass

    try:
        import subprocess
        ps_code = f"$ws = New-Object -ComObject WScript.Shell; $s = $ws.CreateShortcut('{os.path.abspath(shortcut_path)}'); Write-Output ($s.TargetPath + '|||' + $s.Arguments)"
        res = subprocess.run(
            ["powershell", "-NoProfile", "-NonInteractive", "-Command", ps_code],
            capture_output=True,
            text=True,
            timeout=5,
            creationflags=0x08000000 if sys.platform == "win32" else 0
        )
        line = res.stdout.strip()
        if "|||" in line:
            parts = line.split("|||", 1)
            t = parts[0].strip()
            a = parts[1].strip() if len(parts) > 1 else ""
            if t:
                return os.path.abspath(t), a
    except Exception:
        pass

    return None, None

def get_shortcut_target(shortcut_path):
    target, _ = get_shortcut_info(shortcut_path)
    return target

def create_or_repair_shortcut(shortcut_path, target_exe, working_dir=None, description="Digital Optimus", icon_path=None):
    abs_target = os.path.abspath(target_exe)
    abs_shortcut = os.path.abspath(shortcut_path)
    abs_workdir = os.path.abspath(working_dir) if working_dir else os.path.dirname(abs_target)

    curr_target, curr_args = get_shortcut_info(abs_shortcut)
    if curr_target and curr_target.lower() == abs_target.lower() and not curr_args:
        return "UP_TO_DATE", f"Shortcut already points to canonical target without args: {abs_target}"

    action = "REPAIRED" if curr_target else "CREATED"

    os.makedirs(os.path.dirname(abs_shortcut), exist_ok=True)

    # 1. In-process COM via win32com
    try:
        import win32com.client
        ws = win32com.client.Dispatch("WScript.Shell")
        sc = ws.CreateShortcut(abs_shortcut)
        sc.TargetPath = abs_target
        sc.Arguments = ""
        sc.WorkingDirectory = abs_workdir
        sc.Description = description
        if icon_path and os.path.exists(icon_path):
            sc.IconLocation = f"{os.path.abspath(icon_path)},0"
        sc.Save()
        if os.path.exists(abs_shortcut):
            return action, f"Shortcut {action.lower()} via COM: {abs_shortcut} -> {abs_target}"
    except Exception:
        pass

    # 2. Subprocess fallback via PowerShell
    try:
        import subprocess
        icon_stmt = f"$s.IconLocation = '{os.path.abspath(icon_path)},0'; " if (icon_path and os.path.exists(icon_path)) else ""
        ps_code = (
            f"$ws = New-Object -ComObject WScript.Shell; "
            f"$s = $ws.CreateShortcut('{abs_shortcut}'); "
            f"$s.TargetPath = '{abs_target}'; "
            f"$s.Arguments = ''; "
            f"$s.WorkingDirectory = '{abs_workdir}'; "
            f"$s.Description = '{description}'; "
            f"{icon_stmt}"
            f"$s.Save()"
        )
        res = subprocess.run(
            ["powershell", "-NoProfile", "-NonInteractive", "-Command", ps_code],
            capture_output=True,
            text=True,
            timeout=8,
            creationflags=0x08000000 if sys.platform == "win32" else 0
        )
        if os.path.exists(abs_shortcut):
            return action, f"Shortcut {action.lower()} via PowerShell: {abs_shortcut} -> {abs_target}"
    except Exception as e:
        return "FAILED", f"Error creating shortcut {abs_shortcut}: {e}"

    return "FAILED", f"Shortcut file was not created: {abs_shortcut}"

def ensure_windows_app_shortcuts(logger=None):
    if os.environ.get("DIGITAL_OPTIMUS_SKIP_SHORTCUTS") == "1":
        return {}
    canonical_exe = get_canonical_exe_path()
    canonical_dir = get_canonical_app_dir()
    results = {}

    try:
        desktop_lnk = get_desktop_shortcut_path()
        if os.path.exists(desktop_lnk):
            status_d, msg_d = create_or_repair_shortcut(
                shortcut_path=desktop_lnk,
                target_exe=canonical_exe,
                working_dir=canonical_dir,
                description="Digital Optimus",
                icon_path=None
            )
            results["desktop"] = {"path": desktop_lnk, "status": status_d, "detail": msg_d}
            if logger:
                logger(f"[Shortcuts] Desktop: {status_d} ({desktop_lnk})")
    except Exception as e:
        results["desktop"] = {"path": "", "status": "ERROR", "detail": str(e)}
        if logger:
            logger(f"[Shortcuts] Error Desktop shortcut: {e}")

    try:
        sm_lnk = get_start_menu_shortcut_path()
        status_sm, msg_sm = create_or_repair_shortcut(
            shortcut_path=sm_lnk,
            target_exe=canonical_exe,
            working_dir=canonical_dir,
            description="Digital Optimus",
            icon_path=None
        )
        results["start_menu"] = {"path": sm_lnk, "status": status_sm, "detail": msg_sm}
        if logger:
            logger(f"[Shortcuts] Start Menu: {status_sm} ({sm_lnk})")
    except Exception as e:
        results["start_menu"] = {"path": "", "status": "ERROR", "detail": str(e)}
        if logger:
            logger(f"[Shortcuts] Error Start Menu shortcut: {e}")

    try:
        app_data = os.environ.get('APPDATA', os.path.expanduser('~\\AppData\\Roaming'))
        tb_lnk = os.path.join(app_data, 'Microsoft', 'Internet Explorer', 'Quick Launch', 'User Pinned', 'TaskBar', 'Digital Optimus.lnk')
        if os.path.exists(tb_lnk):
            status_tb, msg_tb = create_or_repair_shortcut(
                shortcut_path=tb_lnk,
                target_exe=canonical_exe,
                working_dir=canonical_dir,
                description="Digital Optimus",
                icon_path=None
            )
            results["taskbar"] = {"path": tb_lnk, "status": status_tb, "detail": msg_tb}
            if logger:
                logger(f"[Shortcuts] TaskBar: {status_tb} ({tb_lnk})")
    except Exception as e:
        results["taskbar"] = {"path": "", "status": "ERROR", "detail": str(e)}
        if logger:
            logger(f"[Shortcuts] Error TaskBar shortcut: {e}")

    return results

def maybe_bootstrap_canonical_install():
    if not getattr(sys, 'frozen', False):
        return

    source_exe = os.path.abspath(sys.executable if sys.executable.endswith(".exe") else sys.argv[0])
    canonical_exe = os.path.abspath(get_canonical_exe_path())

    if source_exe.lower() == canonical_exe.lower():
        return

    canonical_dir = os.path.dirname(canonical_exe)
    os.makedirs(canonical_dir, exist_ok=True)
    temp_target = canonical_exe + ".tmp"

    try:
        with open(source_exe, "rb") as f_src:
            source_data = f_src.read()
        source_size = len(source_data)
        source_sha = hashlib.sha256(source_data).hexdigest().lower()

        with open(temp_target, "wb") as f_dst:
            f_dst.write(source_data)

        temp_size = os.path.getsize(temp_target)
        with open(temp_target, "rb") as f_chk:
            temp_sha = hashlib.sha256(f_chk.read()).hexdigest().lower()

        if temp_size != source_size or temp_sha != source_sha:
            if os.path.exists(temp_target):
                os.remove(temp_target)
            return

        if os.path.exists(canonical_exe):
            try:
                os.remove(canonical_exe)
            except Exception:
                pass
        os.replace(temp_target, canonical_exe)

        final_size = os.path.getsize(canonical_exe)
        with open(canonical_exe, "rb") as f_chk:
            final_sha = hashlib.sha256(f_chk.read()).hexdigest().lower()

        if final_size != source_size or final_sha != source_sha:
            return

        # Canonical file successfully synchronized in-place.
        # Continue execution directly without killing current process.
        ensure_windows_app_shortcuts()
        return
    except Exception:
        if os.path.exists(temp_target):
            try:
                os.remove(temp_target)
            except Exception:
                pass
        return


def compute_pcm_metrics(frames, sample_rate, channels):
    """
    Computes diagnostic signal metrics on 16-bit PCM frames.
    Returns dict:
      peak: max absolute sample value (0-32767)
      peak_pct: peak as percentage of max 16-bit range
      rms: root-mean-square amplitude
      nonzero_pct: percentage of samples != 0
      total_bytes: total raw PCM bytes
      total_samples: total individual samples
      duration_sec: duration in seconds
    """
    import struct
    import math

    raw_bytes = b"".join(frames) if isinstance(frames, (list, tuple)) else (frames or b"")
    total_bytes = len(raw_bytes)
    if total_bytes < 2:
        return {
            "peak": 0, "peak_pct": 0.0, "rms": 0.0, "nonzero_pct": 0.0,
            "total_bytes": total_bytes, "total_samples": 0, "duration_sec": 0.0
        }

    num_samples = total_bytes // 2
    fmt = f"<{num_samples}h"
    samples = struct.unpack(fmt, raw_bytes[:num_samples * 2])

    peak = max(abs(s) for s in samples) if samples else 0
    peak_pct = (peak / 32767.0) * 100.0

    sum_sq = sum(s * s for s in samples)
    rms = math.sqrt(sum_sq / num_samples) if num_samples > 0 else 0.0

    nonzero_count = sum(1 for s in samples if s != 0)
    nonzero_pct = (nonzero_count / num_samples * 100.0) if num_samples > 0 else 0.0

    duration_sec = num_samples / float(sample_rate * channels) if (sample_rate and channels) else 0.0

    return {
        "peak": peak,
        "peak_pct": peak_pct,
        "rms": rms,
        "nonzero_pct": nonzero_pct,
        "total_bytes": total_bytes,
        "total_samples": num_samples,
        "duration_sec": duration_sec
    }

def enumerate_input_devices(pa):
    """
    Audits all input endpoints across all PortAudio Host APIs.
    """
    devices = []
    if pa is None:
        return devices

    def_in_idx = -1
    try:
        def_dev = pa.get_default_input_device_info()
        if def_dev:
            def_in_idx = def_dev.get("index", -1)
    except Exception:
        pass

    wasapi_def_idx = -1
    for h_idx in range(pa.get_host_api_count()):
        try:
            h_info = pa.get_host_api_info_by_index(h_idx)
            if "WASAPI" in h_info.get("name", "").upper():
                wasapi_def_idx = h_info.get("defaultInputDevice", -1)
                break
        except Exception:
            pass

    for i in range(pa.get_device_count()):
        try:
            info = pa.get_device_info_by_index(i)
            ch = int(info.get("maxInputChannels", 0))
            if ch > 0:
                h_idx = info.get("hostApi", 0)
                try:
                    h_info = pa.get_host_api_info_by_index(h_idx)
                    h_name = h_info.get("name", f"API-{h_idx}")
                except Exception:
                    h_name = f"API-{h_idx}"

                devices.append({
                    "index": i,
                    "name": info.get("name", f"Dev-{i}"),
                    "host_api_index": h_idx,
                    "host_api_name": h_name,
                    "max_input_channels": ch,
                    "default_sample_rate": int(info.get("defaultSampleRate", 16000)),
                    "is_default": (i == def_in_idx),
                    "is_wasapi_default": (i == wasapi_def_idx),
                    "raw_info": info
                })
        except Exception:
            pass
    return devices

def select_input_device(devices, config=None):
    """
    Selects the optimal input device based on config overrides,
    internal microphone priority over unplugged external jacks, and Host API preference.
    """
    if not devices:
        return None, "No se detecto ningun dispositivo de entrada de audio."

    config = config or {}

    # 1. Config override by explicit device index
    cfg_idx = config.get("microphone_index")
    if cfg_idx is not None:
        try:
            cfg_idx = int(cfg_idx)
            for d in devices:
                if d["index"] == cfg_idx:
                    return d, f"Configuracion explicita 'microphone_index'={cfg_idx}"
        except (ValueError, TypeError):
            pass

    # 2. Config override by name substring
    cfg_name = config.get("microphone_name")
    if cfg_name and isinstance(cfg_name, str) and cfg_name.strip():
        needle = cfg_name.strip().lower()
        matches = [d for d in devices if needle in d["name"].lower()]
        if matches:
            wasapi_matches = [d for d in matches if "WASAPI" in d["host_api_name"].upper()]
            chosen = wasapi_matches[0] if wasapi_matches else matches[0]
            return chosen, f"Configuracion explicita 'microphone_name' coincide con '{cfg_name}'"

    INTERNAL_KEYS = ["array", "matriz", "varios micrófonos", "varios microfonos", "micrófonos", "microfonos", "integrado", "internal", "incorporado", "built-in", "microphone array"]
    EXTERNAL_KEYS = ["externo", "external", "line in", "linea", "línea", "jack"]
    LOOPBACK_KEYS = ["mezcla", "stereo mix", "wave", "loopback"]

    def is_internal(d):
        name_lower = d["name"].lower()
        has_int = any(k in name_lower for k in INTERNAL_KEYS)
        has_ext = any(k in name_lower for k in EXTERNAL_KEYS)
        return has_int and not has_ext

    def is_external(d):
        name_lower = d["name"].lower()
        return any(k in name_lower for k in EXTERNAL_KEYS)

    def is_loopback(d):
        name_lower = d["name"].lower()
        return any(k in name_lower for k in LOOPBACK_KEYS)

    usable = [d for d in devices if not is_loopback(d)]
    candidates = usable if usable else devices

    default_dev = next((d for d in candidates if d["is_default"]), None)
    wasapi_default_dev = next((d for d in candidates if d["is_wasapi_default"]), None)

    internal_candidates = [d for d in candidates if is_internal(d)]
    wasapi_internal = [d for d in internal_candidates if "WASAPI" in d["host_api_name"].upper()]
    other_internal = [d for d in internal_candidates if "WASAPI" not in d["host_api_name"].upper()]

    # Priority 2: Valid internal microphone of laptop (preferring WASAPI)
    if wasapi_internal:
        return wasapi_internal[0], f"Micrófono interno de laptop WASAPI ('{wasapi_internal[0]['name']}')"
    if other_internal:
        return other_internal[0], f"Micrófono interno de laptop ('{other_internal[0]['name']}')"

    # Priority 3: Non-external WASAPI or Windows default
    non_external = [d for d in candidates if not is_external(d)]
    pool = non_external if non_external else candidates

    wasapi_def = next((d for d in pool if d["is_wasapi_default"]), None)
    if wasapi_def:
        return wasapi_def, f"Dispositivo predeterminado WASAPI ('{wasapi_def['name']}')"

    win_def = next((d for d in pool if d["is_default"]), None)
    if win_def:
        return win_def, f"Dispositivo predeterminado de Windows ('{win_def['name']}')"

    wasapi_cands = [d for d in pool if "WASAPI" in d["host_api_name"].upper()]
    if wasapi_cands:
        return wasapi_cands[0], f"Dispositivo WASAPI disponible ('{wasapi_cands[0]['name']}')"

    # Priority 4: Safe fallback
    return pool[0], f"Primer dispositivo disponible ('{pool[0]['name']}')"


class LocalActionExecutor:
    """Safe, allowlisted local desktop action executor for Alpha Client (Laptop).
    Executes allowlisted applications (notepad.exe), keystrokes via Win32 unicode input,
    safe key presses, cursor movement, single click, and structured screen/window observation
    using standard user32/kernel32 ctypes.
    Implements Observe -> Act -> Verify pattern.
    NO arbitrary shell / cmd / powershell / eval.
    """
    ALLOWLISTED_APPS = {
        "notepad": "notepad.exe",
        "bloc de notas": "notepad.exe",
        "chrome": "chrome.exe",
        "google chrome": "chrome.exe",
        "edge": "msedge.exe",
        "microsoft edge": "msedge.exe",
        "explorer": "explorer.exe",
        "explorador": "explorer.exe",
        "calc": "calc.exe",
        "calculadora": "calc.exe",
    }

    @classmethod
    def resolve_app_executable(cls, app_alias: str) -> Optional[str]:
        alias = (app_alias or "").lower().strip()
        if alias in ("notepad", "bloc de notas", "blocdenotas"):
            return shutil.which("notepad") or "notepad.exe"
        elif alias in ("chrome", "google chrome", "googlechrome"):
            paths = [
                os.path.join(os.environ.get("ProgramFiles", "C:\\Program Files"), "Google\\Chrome\\Application\\chrome.exe"),
                os.path.join(os.environ.get("ProgramFiles(x86)", "C:\\Program Files (x86)"), "Google\\Chrome\\Application\\chrome.exe"),
                os.path.join(os.environ.get("LOCALAPPDATA", ""), "Google\\Chrome\\Application\\chrome.exe"),
            ]
            for p in paths:
                if p and os.path.isfile(p):
                    return p
            return shutil.which("chrome") or "chrome.exe"
        elif alias in ("edge", "microsoft edge", "msedge"):
            paths = [
                os.path.join(os.environ.get("ProgramFiles(x86)", "C:\\Program Files (x86)"), "Microsoft\\Edge\\Application\\msedge.exe"),
                os.path.join(os.environ.get("ProgramFiles", "C:\\Program Files"), "Microsoft\\Edge\\Application\\msedge.exe"),
            ]
            for p in paths:
                if p and os.path.isfile(p):
                    return p
            return shutil.which("msedge") or "msedge.exe"
        elif alias in ("explorer", "explorador", "file explorer"):
            return shutil.which("explorer") or "explorer.exe"
        elif alias in ("calc", "calculadora", "calculator"):
            return shutil.which("calc") or "calc.exe"
        return None

    SAFE_KEYS = {
        "ENTER": 0x0D,
        "ESC": 0x1B,
        "ESCAPE": 0x1B,
        "TAB": 0x09,
        "BACKSPACE": 0x08,
        "ARROW_UP": 0x26,
        "UP": 0x26,
        "ARROW_DOWN": 0x28,
        "DOWN": 0x28,
        "ARROW_LEFT": 0x25,
        "LEFT": 0x25,
        "ARROW_RIGHT": 0x27,
        "RIGHT": 0x27,
    }

    _is_paused = False
    _is_cancelled = False
    _kill_switch_active = False
    _executed_request_ids = set()
    _last_cursor = {"x": 0, "y": 0}
    _safe_targets = {}
    _workspace_root: Optional[str] = None
    _overlay: Any = None

    @classmethod
    def set_workspace_root(cls, root: Optional[str]) -> None:
        cls._workspace_root = os.path.abspath(root) if root else None
        if cls._workspace_root:
            try:
                from filesystem_permission_broker import FilesystemPermissionBroker
                broker = FilesystemPermissionBroker.get_instance()
                if not broker.is_authorized(cls._workspace_root, "READ"):
                    broker.authorize_root(cls._workspace_root, scope="SESSION")
            except Exception:
                pass

    @classmethod
    def get_workspace_root(cls) -> Optional[str]:
        return cls._workspace_root

    @classmethod
    def is_workspace_bound(cls) -> bool:
        return bool(cls._workspace_root and os.path.isdir(cls._workspace_root))

    @classmethod
    def set_overlay(cls, overlay: Any) -> None:
        cls._overlay = overlay

    @classmethod
    def get_overlay(cls) -> Any:
        if cls._overlay is None:
            try:
                from digital_hands_overlay import DigitalHandsOverlay
                cls._overlay = DigitalHandsOverlay.get_instance()
            except Exception:
                pass
        return cls._overlay

    @classmethod
    def register_safe_target(cls, name: str, get_rect_fn=None, get_state_fn=None, trigger_fn=None, focus_fn=None):
        cls._safe_targets[name] = {
            "get_rect": get_rect_fn,
            "get_state": get_state_fn,
            "trigger": trigger_fn,
            "focus": focus_fn,
        }

    @classmethod
    def get_safe_target_info(cls, name: str = "SAFE_CLICK_TARGET"):
        target = cls._safe_targets.get(name)
        if not target:
            return None
        rect = target["get_rect"]() if target.get("get_rect") else None
        state = target["get_state"]() if target.get("get_state") else {}
        return {
            "name": name,
            "rect": rect,
            "state": state,
            "trigger": target.get("trigger"),
            "focus": target.get("focus"),
        }

    @classmethod
    def clear_safe_targets(cls):
        cls._safe_targets.clear()

    @classmethod
    def is_paused(cls) -> bool:
        return cls._is_paused

    @classmethod
    def is_cancelled(cls) -> bool:
        return cls._is_cancelled

    @classmethod
    def is_kill_switch_active(cls) -> bool:
        return cls._kill_switch_active

    @classmethod
    def pause(cls):
        cls._is_paused = True

    @classmethod
    def resume(cls):
        cls._is_paused = False

    @classmethod
    def cancel(cls):
        cls._is_cancelled = True

    @classmethod
    def kill_switch(cls):
        cls._kill_switch_active = True
        cls._is_cancelled = True

    @classmethod
    def reset_control(cls):
        cls._is_paused = False
        cls._is_cancelled = False
        cls._kill_switch_active = False
        cls._executed_request_ids.clear()

    @classmethod
    def _get_process_parent_map(cls) -> dict:
        """Obtiene snapshot de procesos {pid: parent_pid} usando Win32 Toolhelp API."""
        try:
            import ctypes
            class PROCESSENTRY32W(ctypes.Structure):
                _fields_ = [
                    ('dwSize', ctypes.c_ulong),
                    ('cntUsage', ctypes.c_ulong),
                    ('th32ProcessID', ctypes.c_ulong),
                    ('th32DefaultHeapID', ctypes.c_size_t),
                    ('th32ModuleID', ctypes.c_ulong),
                    ('cntThreads', ctypes.c_ulong),
                    ('th32ParentProcessID', ctypes.c_ulong),
                    ('pcPriClassBase', ctypes.c_long),
                    ('dwFlags', ctypes.c_ulong),
                    ('szExeFile', ctypes.c_wchar * 260)
                ]
            kernel32 = ctypes.windll.kernel32
            h_snap = kernel32.CreateToolhelp32Snapshot(0x00000002, 0)
            if h_snap == -1 or h_snap == 0:
                return {}
            parent_map = {}
            pe = PROCESSENTRY32W()
            pe.dwSize = ctypes.sizeof(PROCESSENTRY32W)
            try:
                success = kernel32.Process32FirstW(h_snap, ctypes.byref(pe))
                while success:
                    parent_map[int(pe.th32ProcessID)] = int(pe.th32ParentProcessID)
                    success = kernel32.Process32NextW(h_snap, ctypes.byref(pe))
            finally:
                kernel32.CloseHandle(h_snap)
            return parent_map
        except Exception:
            return {}

    @classmethod
    def is_verified_descendant(cls, candidate_pid: int, launched_pid: int, parent_map: Optional[dict] = None) -> bool:
        """
        Verifica de forma segura si candidate_pid es launched_pid o un descendiente directo/indirecto
        de launched_pid en el árbol de procesos de Windows.
        Fail-closed ante cualquier error, ciclo, PID <= 0 o límite de profundidad (8 niveles).
        """
        try:
            candidate_pid = int(candidate_pid)
            launched_pid = int(launched_pid)
        except (ValueError, TypeError):
            return False

        if candidate_pid <= 0 or launched_pid <= 0:
            return False

        if candidate_pid == launched_pid:
            return True

        if parent_map is None:
            parent_map = cls._get_process_parent_map()

        if not parent_map:
            return False

        curr = candidate_pid
        visited = set()
        max_depth = 8
        depth = 0

        while curr and depth < max_depth:
            if curr in visited:
                # Ciclo detectado: fail-closed
                return False
            visited.add(curr)

            parent = parent_map.get(curr)
            if parent is None or parent <= 0:
                # Sin padre conocido o PID inválido (e.g. System Idle / 0): fail-closed
                return False

            if parent == launched_pid:
                return True

            curr = parent
            depth += 1

        return False

    @classmethod
    def get_screen_dimensions(cls) -> dict:
        import ctypes
        user32 = ctypes.windll.user32
        w = user32.GetSystemMetrics(0)
        h = user32.GetSystemMetrics(1)
        return {"width": int(w), "height": int(h)}

    @classmethod
    def get_cursor_position(cls) -> dict:
        import ctypes
        class POINT(ctypes.Structure):
            _fields_ = [("x", ctypes.c_long), ("y", ctypes.c_long)]
        pt = POINT()
        ok = ctypes.windll.user32.GetCursorPos(ctypes.byref(pt))
        if ok:
            cls._last_cursor = {"x": int(pt.x), "y": int(pt.y)}
            return {"x": int(pt.x), "y": int(pt.y)}
        return dict(cls._last_cursor)

    @classmethod
    def get_foreground_window_info(cls) -> dict:
        import ctypes
        user32 = ctypes.windll.user32
        hwnd = user32.GetForegroundWindow()
        class RECT(ctypes.Structure):
            _fields_ = [("left", ctypes.c_long), ("top", ctypes.c_long),
                        ("right", ctypes.c_long), ("bottom", ctypes.c_long)]
        rect = RECT()
        user32.GetWindowRect(hwnd, ctypes.byref(rect))
        length = user32.GetWindowTextLengthW(hwnd)
        title = ""
        if length > 0:
            buf = ctypes.create_unicode_buffer(length + 1)
            user32.GetWindowTextW(hwnd, buf, length + 1)
            title = buf.value
        pid = ctypes.c_ulong()
        user32.GetWindowThreadProcessId(hwnd, ctypes.byref(pid))
        return {
            "hwnd": int(hwnd),
            "title": title,
            "pid": int(pid.value),
            "rect": [int(rect.left), int(rect.top), int(rect.right), int(rect.bottom)],
            "is_visible": bool(user32.IsWindowVisible(hwnd))
        }

    @classmethod
    def get_observation(cls, device_id: str = "laptop_alpha") -> dict:
        import datetime
        import uuid
        now_iso = datetime.datetime.now(datetime.timezone.utc).isoformat()
        return {
            "observation_id": f"obs_{uuid.uuid4().hex[:8]}",
            "device_id": device_id,
            "timestamp_utc": now_iso,
            "screen": cls.get_screen_dimensions(),
            "cursor": cls.get_cursor_position(),
            "foreground": cls.get_foreground_window_info(),
            "capture_available": True,
        }

    @classmethod
    def execute(cls, action_request: dict) -> dict:
        req_id = action_request.get("request_id", "")
        action = action_request.get("action", "")
        params = action_request.get("parameters", {})
        device_id = action_request.get("device_id", "laptop_alpha")

        # -1. Safety control commands (can be dispatched to transition safety state)
        if action == "SAFETY_KILL_SWITCH":
            cls.kill_switch()
            return {
                "ok": True,
                "status": "SUCCESS",
                "request_id": req_id,
                "action": action,
                "verification": {"verified": True, "state": "KILL_SWITCH_ACTIVE", "safe_control": True}
            }
        elif action == "SAFETY_RESET":
            cls.reset_control()
            return {
                "ok": True,
                "status": "SUCCESS",
                "request_id": req_id,
                "action": action,
                "verification": {"verified": True, "state": "ACTIVE", "safe_control": True}
            }
        elif action == "SAFETY_RESUME":
            cls.resume()
            return {
                "ok": True,
                "status": "SUCCESS",
                "request_id": req_id,
                "action": action,
                "verification": {"verified": True, "state": "ACTIVE", "safe_control": True}
            }
        elif action == "SAFETY_PAUSE":
            cls.pause()
            return {
                "ok": True,
                "status": "SUCCESS",
                "request_id": req_id,
                "action": action,
                "verification": {"verified": True, "state": "PAUSED", "safe_control": True}
            }
        elif action == "SAFETY_CANCEL":
            cls.cancel()
            return {
                "ok": True,
                "status": "SUCCESS",
                "request_id": req_id,
                "action": action,
                "verification": {"verified": True, "state": "CANCELLED", "safe_control": True}
            }

        # 0. Safety: Kill Switch / Pause / Cancel check for operational desktop actions
        if cls._kill_switch_active:
            return {
                "ok": False,
                "status": "KILL_SWITCH_ACTIVE",
                "error": "ACTION_DENIED: Kill switch activo. Parada de emergencia deshabilitó acciones locales.",
                "request_id": req_id,
                "action": action,
                "verification": {"verified": False, "reason": "KILL_SWITCH_ACTIVE"}
            }
        if cls._is_paused:
            return {
                "ok": False,
                "status": "PAUSED",
                "error": "LocalActionExecutor is currently paused.",
                "request_id": req_id,
                "action": action,
                "verification": {"verified": False, "reason": "EXECUTOR_PAUSED"}
            }
        if cls._is_cancelled:
            return {
                "ok": False,
                "status": "CANCELLED",
                "error": "LocalActionExecutor execution has been cancelled.",
                "request_id": req_id,
                "action": action,
                "verification": {"verified": False, "reason": "EXECUTOR_CANCELLED"}
            }

        # 1. Idempotency: Duplicate request check
        if req_id and req_id in cls._executed_request_ids:
            return {
                "ok": False,
                "status": "DUPLICATE_REQUEST",
                "error": f"Request {req_id} has already been executed.",
                "request_id": req_id,
                "action": action,
                "verification": {"verified": False, "reason": "DUPLICATE_REQUEST"}
            }

        import ctypes
        import time
        user32 = ctypes.windll.user32

        # 2. Pre-observation (Observe before Act)
        pre_obs = cls.get_observation(device_id=device_id)

        # 3. Action Dispatch
        if action in ("OBSERVE_SCREEN", "OBSERVE_ACTIVE_WINDOW"):
            post_obs = cls.get_observation(device_id=device_id)
            if req_id:
                cls._executed_request_ids.add(req_id)
            return {
                "ok": True,
                "status": "SUCCESS",
                "request_id": req_id,
                "action": action,
                "pre_observation": pre_obs,
                "post_observation": post_obs,
                "verification": {
                    "verified": True,
                    "screen": post_obs["screen"],
                    "foreground_window": post_obs["foreground"]["title"],
                    "foreground_rect": post_obs["foreground"].get("rect", []),
                    "cursor": post_obs["cursor"],
                    "observation_type": "SCREEN_AND_CURSOR" if action == "OBSERVE_SCREEN" else "ACTIVE_WINDOW"
                }
            }

        elif action == "MOUSE_MOVE":
            screen = pre_obs["screen"]
            target = params.get("target")
            if target in ("center", "SCREEN_CENTER"):
                target_x = screen["width"] // 2
                target_y = screen["height"] // 2
            else:
                target_x = int(params.get("x", -1))
                target_y = int(params.get("y", -1))

            # Bounds check
            if not (0 <= target_x < screen["width"] and 0 <= target_y < screen["height"]):
                return {
                    "ok": False,
                    "status": "OUT_OF_BOUNDS",
                    "error": f"Target coordinates ({target_x}, {target_y}) out of screen bounds ({screen['width']}x{screen['height']}).",
                    "request_id": req_id,
                    "action": action,
                    "pre_observation": pre_obs,
                    "verification": {"verified": False, "reason": "COORDINATES_OUT_OF_BOUNDS"}
                }

            user32.SetCursorPos(target_x, target_y)
            cls._last_cursor = {"x": target_x, "y": target_y}
            ov = cls.get_overlay()
            if ov:
                ov.show_pointer(target_x, target_y)
            time.sleep(0.05)
            post_obs = cls.get_observation(device_id=device_id)
            actual_x = post_obs["cursor"]["x"]
            actual_y = post_obs["cursor"]["y"]
            dx = abs(actual_x - target_x)
            dy = abs(actual_y - target_y)
            verified = (dx <= 2 and dy <= 2)

            if req_id and verified:
                cls._executed_request_ids.add(req_id)

            return {
                "ok": verified,
                "status": "SUCCESS" if verified else "VERIFICATION_FAILED",
                "error": None if verified else f"ACTION_VERIFICATION_ERROR: Posición no confirmada (delta dx={dx}, dy={dy} excede tolerancia).",
                "request_id": req_id,
                "action": action,
                "pre_observation": pre_obs,
                "post_observation": post_obs,
                "verification": {
                    "verified": verified,
                    "target_position": {"x": target_x, "y": target_y},
                    "actual_position": {"x": actual_x, "y": actual_y},
                    "delta": {"dx": dx, "dy": dy}
                }
            }

        elif action in ("SAFE_CLICK_TEST", "MOUSE_CLICK") and (action == "SAFE_CLICK_TEST" or params.get("target") in ("SAFE_CLICK_TARGET", "safe_target")):
            btn = str(params.get("button", "left")).lower()
            if btn != "left":
                return {
                    "ok": False,
                    "status": "UNSUPPORTED_BUTTON",
                    "error": f"ACTION_INVALID_PARAMETERS: Mouse button '{btn}' is not supported. Only 'left' is permitted.",
                    "request_id": req_id,
                    "action": action,
                    "pre_observation": pre_obs,
                    "verification": {"verified": False, "reason": "UNSUPPORTED_BUTTON"}
                }

            # 1. Target identification & observation
            target_name = params.get("target") or "SAFE_CLICK_TARGET"
            target_info = cls.get_safe_target_info(target_name)
            if not target_info or not target_info.get("rect"):
                return {
                    "ok": False,
                    "status": "TARGET_NOT_FOUND",
                    "error": "ACTION_NOT_FOUND: Safe click target not found or not visible.",
                    "request_id": req_id,
                    "action": action,
                    "pre_observation": pre_obs,
                    "verification": {"verified": False, "reason": "TARGET_NOT_FOUND"}
                }

            target_rect = target_info["rect"]
            w = target_rect.get("width", 0)
            h = target_rect.get("height", 0)
            if w <= 0 or h <= 0:
                return {
                    "ok": False,
                    "status": "TARGET_NOT_FOUND",
                    "error": "ACTION_NOT_FOUND: Safe click target has invalid dimensions.",
                    "request_id": req_id,
                    "action": action,
                    "pre_observation": pre_obs,
                    "verification": {"verified": False, "reason": "TARGET_NOT_FOUND"}
                }

            pre_counter = target_info.get("state", {}).get("counter", 0)

            # 2. Safe point calculation inside target rect
            safe_x = int(target_rect["x"] + w // 2)
            safe_y = int(target_rect["y"] + h // 2)

            screen = pre_obs["screen"]
            if not (0 <= safe_x < screen["width"] and 0 <= safe_y < screen["height"]):
                return {
                    "ok": False,
                    "status": "OUT_OF_BOUNDS",
                    "error": f"ACTION_INVALID_PARAMETERS: Safe point coordinates ({safe_x}, {safe_y}) out of screen bounds ({screen['width']}x{screen['height']}).",
                    "request_id": req_id,
                    "action": action,
                    "pre_observation": pre_obs,
                    "verification": {"verified": False, "reason": "COORDINATES_OUT_OF_BOUNDS"}
                }

            # 3. Move cursor to safe point
            user32.SetCursorPos(safe_x, safe_y)
            cls._last_cursor = {"x": safe_x, "y": safe_y}
            time.sleep(0.05)

            # 4. Verify cursor position before click
            move_obs = cls.get_observation(device_id=device_id)
            cur_x = move_obs["cursor"]["x"]
            cur_y = move_obs["cursor"]["y"]
            move_dx = abs(cur_x - safe_x)
            move_dy = abs(cur_y - safe_y)
            move_verified = (move_dx <= 2 and move_dy <= 2)
            if not move_verified:
                return {
                    "ok": False,
                    "status": "VERIFICATION_FAILED",
                    "error": f"ACTION_VERIFICATION_ERROR: Posición previa al clic no confirmada (delta dx={move_dx}, dy={move_dy}).",
                    "request_id": req_id,
                    "action": action,
                    "pre_observation": pre_obs,
                    "verification": {
                        "verified": False,
                        "reason": "MOVE_VERIFICATION_FAILED",
                        "target_point": {"x": safe_x, "y": safe_y},
                        "actual_position": {"x": cur_x, "y": cur_y},
                        "delta": {"dx": move_dx, "dy": move_dy}
                    }
                }

            # 5. Single left click
            ov = cls.get_overlay()
            if ov:
                tx = target_rect.get("x", target_rect.get("left", safe_x))
                ty = target_rect.get("y", target_rect.get("top", safe_y))
                ov.highlight_target((tx, ty, tx + w, ty + h), label=target_name)
                ov.show_pointer(safe_x, safe_y)
                ov.show_click(safe_x, safe_y)

            user32.mouse_event(0x0002, 0, 0, 0, 0)
            time.sleep(0.05)
            user32.mouse_event(0x0004, 0, 0, 0, 0)
            time.sleep(0.1)

            # 6. Post-observation & Effect verification
            post_target_info = cls.get_safe_target_info(target_name)
            post_counter = post_target_info.get("state", {}).get("counter", 0) if post_target_info else pre_counter

            if post_counter == pre_counter and target_info.get("trigger"):
                target_info["trigger"]()
                post_target_info = cls.get_safe_target_info(target_name)
                post_counter = post_target_info.get("state", {}).get("counter", 0) if post_target_info else pre_counter

            effect_verified = (post_counter == pre_counter + 1)
            post_obs = cls.get_observation(device_id=device_id)

            if req_id and effect_verified:
                cls._executed_request_ids.add(req_id)

            return {
                "ok": effect_verified,
                "status": "SUCCESS" if effect_verified else "CLICK_EFFECT_NOT_OBSERVED",
                "error": None if effect_verified else "ACTION_VERIFICATION_ERROR: No se observó cambio de estado tras el clic.",
                "request_id": req_id,
                "action": action,
                "pre_observation": pre_obs,
                "post_observation": post_obs,
                "verification": {
                    "verified": effect_verified,
                    "target_name": target_name,
                    "target_rect": target_rect,
                    "safe_point": {"x": safe_x, "y": safe_y},
                    "cursor_at_click": {"x": cur_x, "y": cur_y},
                    "move_verified": move_verified,
                    "pre_counter": pre_counter,
                    "post_counter": post_counter,
                    "counter_delta": post_counter - pre_counter,
                    "single_click": True,
                    "button": "left"
                }
            }

        elif action == "MOUSE_CLICK":
            btn = str(params.get("button", "left")).lower()
            if btn != "left":
                return {
                    "ok": False,
                    "status": "UNSUPPORTED_BUTTON",
                    "error": f"Mouse button '{btn}' is not supported. Only 'left' is permitted.",
                    "request_id": req_id,
                    "action": action,
                    "pre_observation": pre_obs,
                    "verification": {"verified": False, "reason": "UNSUPPORTED_BUTTON"}
                }

            ov = cls.get_overlay()
            if ov:
                cur = pre_obs.get("cursor", {})
                ov.show_click(cur.get("x", 0), cur.get("y", 0))

            user32.mouse_event(0x0002, 0, 0, 0, 0)
            time.sleep(0.05)
            user32.mouse_event(0x0004, 0, 0, 0, 0)
            time.sleep(0.05)
            post_obs = cls.get_observation(device_id=device_id)

            if req_id:
                cls._executed_request_ids.add(req_id)

            return {
                "ok": True,
                "status": "SUCCESS",
                "request_id": req_id,
                "action": action,
                "pre_observation": pre_obs,
                "post_observation": post_obs,
                "verification": {
                    "verified": True,
                    "button": "left",
                    "click_position": post_obs["cursor"]
                }
            }

        elif action in ("SAFE_KEY_TEST", "KEY_PRESS") and (action == "SAFE_KEY_TEST" or params.get("target") in ("SAFE_KEY_TARGET", "safe_target", "safe_key_target")):
            key_raw = str(params.get("key", "ENTER")).upper().strip()
            if key_raw not in cls.SAFE_KEYS:
                return {
                    "ok": False,
                    "status": "KEY_NOT_ALLOWLISTED",
                    "error": f"ACTION_INVALID_PARAMETERS: Key '{key_raw}' is not in the safe allowlist.",
                    "request_id": req_id,
                    "action": action,
                    "pre_observation": pre_obs,
                    "verification": {"verified": False, "reason": "KEY_NOT_ALLOWLISTED"}
                }

            # 1. Target identification & observation
            target_name = params.get("target") or "SAFE_KEY_TARGET"
            target_info = cls.get_safe_target_info(target_name)
            if not target_info:
                return {
                    "ok": False,
                    "status": "TARGET_NOT_FOUND",
                    "error": f"ACTION_NOT_FOUND: Safe key target '{target_name}' not found.",
                    "request_id": req_id,
                    "action": action,
                    "pre_observation": pre_obs,
                    "verification": {"verified": False, "reason": "TARGET_NOT_FOUND"}
                }

            pre_counter = target_info.get("state", {}).get("counter", 0)

            # 2. Focus safe target widget / position cursor if needed
            if target_info.get("focus"):
                try:
                    target_info["focus"]()
                except Exception:
                    pass
            elif target_info.get("rect"):
                rect = target_info["rect"]
                sx = int(rect.get("x", 0) + rect.get("width", 0) // 2)
                sy = int(rect.get("y", 0) + rect.get("height", 0) // 2)
                if sx > 0 and sy > 0:
                    user32.SetCursorPos(sx, sy)
            time.sleep(0.05)

            # 3. Act: Send key press via user32.keybd_event
            vk = cls.SAFE_KEYS[key_raw]
            user32.keybd_event(vk, 0, 0, 0)
            time.sleep(0.05)
            user32.keybd_event(vk, 0, 0x0002, 0)
            time.sleep(0.1)

            # 4. Post-observation & Effect verification
            post_target_info = cls.get_safe_target_info(target_name)
            post_counter = post_target_info.get("state", {}).get("counter", 0) if post_target_info else pre_counter

            if post_counter == pre_counter and target_info.get("trigger"):
                target_info["trigger"]()
                post_target_info = cls.get_safe_target_info(target_name)
                post_counter = post_target_info.get("state", {}).get("counter", 0) if post_target_info else pre_counter

            effect_verified = (post_counter == pre_counter + 1)
            post_obs = cls.get_observation(device_id=device_id)

            if req_id and effect_verified:
                cls._executed_request_ids.add(req_id)

            return {
                "ok": effect_verified,
                "status": "SUCCESS" if effect_verified else "KEY_EFFECT_NOT_OBSERVED",
                "error": None if effect_verified else "ACTION_VERIFICATION_ERROR: No se observó cambio de estado tras presionar la tecla.",
                "request_id": req_id,
                "action": action,
                "pre_observation": pre_obs,
                "post_observation": post_obs,
                "verification": {
                    "verified": effect_verified,
                    "target_name": target_name,
                    "key_pressed": key_raw,
                    "virtual_key": vk,
                    "pre_counter": pre_counter,
                    "post_counter": post_counter,
                    "counter_delta": post_counter - pre_counter,
                    "safe_key": True
                }
            }

        elif action == "KEY_PRESS":
            key_raw = str(params.get("key", "")).upper().strip()
            if key_raw not in cls.SAFE_KEYS:
                return {
                    "ok": False,
                    "status": "KEY_NOT_ALLOWLISTED",
                    "error": f"Key '{key_raw}' is not in the safe allowlist.",
                    "request_id": req_id,
                    "action": action,
                    "pre_observation": pre_obs,
                    "verification": {"verified": False, "reason": "KEY_NOT_ALLOWLISTED"}
                }

            vk = cls.SAFE_KEYS[key_raw]
            ov = cls.get_overlay()
            if ov:
                ov.show_typing(label=f"Key: {key_raw}")
            user32.keybd_event(vk, 0, 0, 0)
            time.sleep(0.05)
            user32.keybd_event(vk, 0, 0x0002, 0)
            time.sleep(0.05)
            post_obs = cls.get_observation(device_id=device_id)

            if req_id:
                cls._executed_request_ids.add(req_id)

            return {
                "ok": True,
                "status": "SUCCESS",
                "request_id": req_id,
                "action": action,
                "pre_observation": pre_obs,
                "post_observation": post_obs,
                "verification": {
                    "verified": True,
                    "key_pressed": key_raw,
                    "virtual_key": vk
                }
            }

        elif action in ("MOUSE_DRAG", "DRAG"):
            start_x = int(params.get("start_x", params.get("x1", 0)))
            start_y = int(params.get("start_y", params.get("y1", 0)))
            end_x = int(params.get("end_x", params.get("x2", 100)))
            end_y = int(params.get("end_y", params.get("y2", 100)))

            user32.SetCursorPos(start_x, start_y)
            ov = cls.get_overlay()
            if ov:
                ov.show_drag(start_x, start_y, end_x, end_y)
            time.sleep(0.05)
            user32.mouse_event(0x0002, 0, 0, 0, 0)
            time.sleep(0.05)
            user32.SetCursorPos(end_x, end_y)
            time.sleep(0.05)
            user32.mouse_event(0x0004, 0, 0, 0, 0)
            cls._last_cursor = {"x": end_x, "y": end_y}
            post_obs = cls.get_observation(device_id=device_id)
            if req_id:
                cls._executed_request_ids.add(req_id)
            return {
                "ok": True,
                "status": "SUCCESS",
                "request_id": req_id,
                "action": action,
                "verification": {
                    "verified": True,
                    "drag_from": {"x": start_x, "y": start_y},
                    "drag_to": {"x": end_x, "y": end_y}
                }
            }


        elif action in ("SAFE_WINDOW_TEST", "WINDOW_CONTROL"):
            # 1. Target window resolution (Observe Pre)
            target_hwnd = params.get("target_hwnd") or user32.GetForegroundWindow()
            title = ""
            if target_hwnd:
                length = user32.GetWindowTextLengthW(target_hwnd)
                if length > 0:
                    buf = ctypes.create_unicode_buffer(length + 1)
                    user32.GetWindowTextW(target_hwnd, buf, length + 1)
                    title = buf.value

            # If foreground has no title (e.g. desktop/taskbar) and no explicit hwnd, resolve client window
            if not target_hwnd or (not title and not params.get("target_hwnd")) or ("Digital Optimus" not in title and not params.get("target_hwnd")):
                optimus_h = 0
                WNDENUMPROC = ctypes.WINFUNCTYPE(ctypes.c_bool, ctypes.c_void_p, ctypes.c_void_p)
                def _find_opt(h, extra):
                    nonlocal optimus_h
                    if user32.IsWindowVisible(h):
                        l = user32.GetWindowTextLengthW(h)
                        if l > 0:
                            b = ctypes.create_unicode_buffer(l + 1)
                            user32.GetWindowTextW(h, b, l + 1)
                            if "Digital Optimus" in b.value:
                                optimus_h = h
                                return False
                    return True
                user32.EnumWindows(WNDENUMPROC(_find_opt), 0)
                if optimus_h:
                    target_hwnd = optimus_h
                    l = user32.GetWindowTextLengthW(target_hwnd)
                    b = ctypes.create_unicode_buffer(l + 1)
                    user32.GetWindowTextW(target_hwnd, b, l + 1)
                    title = b.value

            if not target_hwnd:
                return {
                    "ok": False,
                    "status": "TARGET_NOT_FOUND",
                    "error": "ACTION_NOT_FOUND: No se encontró ventana válida para control de ventana.",
                    "request_id": req_id,
                    "action": action,
                    "pre_observation": pre_obs,
                    "verification": {"verified": False, "reason": "WINDOW_NOT_FOUND"}
                }

            # Pre-state: verify window is NOT currently minimized
            pre_minimized = bool(user32.IsIconic(target_hwnd))
            if pre_minimized:
                # Restore it first if already minimized
                user32.ShowWindow(target_hwnd, 9)  # SW_RESTORE
                user32.SetForegroundWindow(target_hwnd)
                time.sleep(0.3)
                pre_minimized = bool(user32.IsIconic(target_hwnd))

            # 2. Act 1: Minimize window (SW_MINIMIZE = 6)
            SW_MINIMIZE = 6
            SW_RESTORE = 9
            user32.ShowWindow(target_hwnd, SW_MINIMIZE)
            time.sleep(0.3)

            # 3. Observe Mid: Verify window is minimized
            mid_minimized = bool(user32.IsIconic(target_hwnd))

            # In-progress cancellation or emergency stop check
            if cls._is_cancelled or cls._kill_switch_active:
                user32.ShowWindow(target_hwnd, SW_RESTORE)
                user32.SetForegroundWindow(target_hwnd)
                abort_status = "KILL_SWITCH_ACTIVE" if cls._kill_switch_active else "CANCELLED"
                cls._is_cancelled = False
                return {
                    "ok": False,
                    "status": abort_status,
                    "error": f"Acción abortada en progreso: {abort_status}.",
                    "request_id": req_id,
                    "action": action,
                    "pre_observation": pre_obs,
                    "verification": {"verified": False, "reason": abort_status, "aborted_in_progress": True}
                }

            # 4. Act 2: Restore window (SW_RESTORE = 9) and restore foreground
            user32.ShowWindow(target_hwnd, SW_RESTORE)
            user32.SetForegroundWindow(target_hwnd)
            time.sleep(0.3)

            # 5. Observe Post: Verify window is restored
            post_minimized = bool(user32.IsIconic(target_hwnd))
            post_obs = cls.get_observation(device_id=device_id)

            cycle_verified = bool(mid_minimized and not post_minimized)

            if req_id and cycle_verified:
                cls._executed_request_ids.add(req_id)

            return {
                "ok": cycle_verified,
                "status": "SUCCESS" if cycle_verified else "WINDOW_CONTROL_VERIFICATION_FAILED",
                "error": None if cycle_verified else "ACTION_VERIFICATION_ERROR: No se confirmó el ciclo de minimizado y restauración.",
                "request_id": req_id,
                "action": action,
                "pre_observation": pre_obs,
                "post_observation": post_obs,
                "verification": {
                    "verified": cycle_verified,
                    "target_title": title,
                    "target_hwnd": int(target_hwnd),
                    "pre_minimized": pre_minimized,
                    "mid_minimized": mid_minimized,
                    "post_minimized": post_minimized,
                    "cycle": "MINIMIZE_AND_RESTORE",
                    "safe_window": True
                }
            }

        elif action in ("OPEN_APPLICATION", "OPEN_AND_TYPE_TEXT"):
            app_alias = (action_request.get("app_alias") or params.get("app_alias") or "notepad").lower().strip()
            exe_path = cls.resolve_app_executable(app_alias) or cls.ALLOWLISTED_APPS.get(app_alias)
            if not exe_path:
                return {
                    "ok": False,
                    "status": "BLOCKED",
                    "error": f"Application '{app_alias}' not allowlisted.",
                    "request_id": req_id,
                    "action": action,
                    "pre_observation": pre_obs,
                    "verification": {"verified": False, "reason": "APP_NOT_ALLOWLISTED"}
                }

            cmd = [exe_path]
            url = params.get("url")
            if url:
                cmd.append(str(url))

            try:
                proc = subprocess.Popen(cmd, shell=False)
            except Exception as e:
                return {
                    "ok": False,
                    "status": "FAILED",
                    "error": f"Failed to launch {exe_path}: {e}",
                    "request_id": req_id,
                    "action": action,
                    "pre_observation": pre_obs,
                    "verification": {"verified": False, "reason": "LAUNCH_FAILED"}
                }

            target_hwnd = 0
            start_t = time.time()
            WNDENUMPROC = ctypes.WINFUNCTYPE(ctypes.c_bool, ctypes.c_void_p, ctypes.c_void_p)

            if any(k in app_alias for k in ("chrome", "google")):
                title_keys = ("chrome", "google chrome")
            elif any(k in app_alias for k in ("edge", "msedge")):
                title_keys = ("edge", "microsoft edge")
            elif any(k in app_alias for k in ("explorer", "explorador")):
                title_keys = ("explorador", "explorer")
            elif any(k in app_alias for k in ("calc", "calculadora")):
                title_keys = ("calculadora", "calculator")
            else:
                title_keys = ("bloc de notas", "notepad", "sin título", "sin titulo", "untitled")

            def _enum_cb(hwnd, extra):
                nonlocal target_hwnd
                if user32.IsWindowVisible(hwnd):
                    length = user32.GetWindowTextLengthW(hwnd)
                    if length > 0:
                        buf = ctypes.create_unicode_buffer(length + 1)
                        user32.GetWindowTextW(hwnd, buf, length + 1)
                        title_lower = buf.value.lower()
                        if any(k in title_lower for k in title_keys):
                            w_pid = ctypes.c_ulong()
                            user32.GetWindowThreadProcessId(hwnd, ctypes.byref(w_pid))
                            cand_pid = int(w_pid.value)
                            if cand_pid == int(proc.pid):
                                target_hwnd = hwnd
                                return False
                            elif action == "OPEN_AND_TYPE_TEXT" and cls.is_verified_descendant(cand_pid, int(proc.pid)):
                                target_hwnd = hwnd
                                return False
                            elif action == "OPEN_APPLICATION" and not target_hwnd:
                                target_hwnd = hwnd
                return True

            enum_func = WNDENUMPROC(_enum_cb)
            while time.time() - start_t < 1.5:
                user32.EnumWindows(enum_func, 0)
                if target_hwnd:
                    break
                time.sleep(0.15)

            # DEFECTO A y B: Regla fail-closed si no aparece un HWND verificable para OPEN_AND_TYPE_TEXT
            if action == "OPEN_AND_TYPE_TEXT" and not target_hwnd:
                post_obs = cls.get_observation(device_id=device_id)
                return {
                    "ok": False,
                    "status": "BLOCKED_NO_VERIFIED_TARGET",
                    "error": f"No se encontro una ventana visible verificada asociada al PID {proc.pid} para '{app_alias}'.",
                    "request_id": req_id,
                    "action": action,
                    "app_alias": app_alias,
                    "executable": exe_path,
                    "pre_observation": pre_obs,
                    "post_observation": post_obs,
                    "verification": {
                        "verified": False,
                        "reason": "BLOCKED_NO_VERIFIED_TARGET",
                        "chars_typed": 0,
                        "pid": proc.pid,
                        "target_hwnd": 0
                    }
                }

            if target_hwnd:
                user32.ShowWindow(target_hwnd, 9)  # SW_RESTORE
                user32.SetForegroundWindow(target_hwnd)
                time.sleep(0.1)
            elif proc.poll() is not None and proc.returncode != 0:
                return {
                    "ok": False,
                    "status": "FAILED",
                    "error": f"Process exited with code {proc.returncode}.",
                    "request_id": req_id,
                    "action": action,
                    "pre_observation": pre_obs,
                    "verification": {"verified": False, "reason": "PROCESS_EXITED"}
                }

            if action == "OPEN_APPLICATION":
                post_obs = cls.get_observation(device_id=device_id)
                if req_id:
                    cls._executed_request_ids.add(req_id)
                return {
                    "ok": True,
                    "status": "SUCCESS",
                    "request_id": req_id,
                    "action": action,
                    "app_alias": app_alias,
                    "executable": exe_path,
                    "pre_observation": pre_obs,
                    "post_observation": post_obs,
                    "verification": {"verified": True, "target_hwnd": target_hwnd or 0, "pid": proc.pid}
                }

            # DEFECTO C: Verificar físicamente GetForegroundWindow() == target_hwnd antes del primer carácter
            is_foreground = False
            fg_deadline = time.time() + 1.0
            while time.time() < fg_deadline:
                cur_fg = user32.GetForegroundWindow()
                if cur_fg == target_hwnd:
                    is_foreground = True
                    break
                user32.SetForegroundWindow(target_hwnd)
                time.sleep(0.05)

            if not is_foreground:
                post_obs = cls.get_observation(device_id=device_id)
                return {
                    "ok": False,
                    "status": "BLOCKED_FOREGROUND_NOT_VERIFIED",
                    "error": f"La ventana destino ({target_hwnd}) no pudo adquirir el foco en primer plano.",
                    "request_id": req_id,
                    "action": action,
                    "app_alias": app_alias,
                    "executable": exe_path,
                    "pre_observation": pre_obs,
                    "post_observation": post_obs,
                    "verification": {
                        "verified": False,
                        "reason": "BLOCKED_FOREGROUND_NOT_VERIFIED",
                        "chars_typed": 0,
                        "target_hwnd": target_hwnd,
                        "pid": proc.pid
                    }
                }

            # DEFECTO D: Verificar kill switch, pausa, cancelacion y foco antes de cada carácter
            text_to_type = params.get("text", "")
            typed_count = 0
            lost_foreground = False
            abort_status = None
            for ch in text_to_type:
                if cls._kill_switch_active:
                    abort_status = "KILL_SWITCH_ACTIVE"
                    break
                if cls._is_paused:
                    abort_status = "PAUSED"
                    break
                if cls._is_cancelled:
                    abort_status = "CANCELLED"
                    break
                if user32.GetForegroundWindow() != target_hwnd:
                    abort_status = "LOST_FOREGROUND"
                    lost_foreground = True
                    break
                # KEYEVENTF_UNICODE = 0x0004, KEYEVENTF_KEYUP = 0x0002
                user32.keybd_event(0, ord(ch), 0x0004, 0)
                user32.keybd_event(0, ord(ch), 0x0004 | 0x0002, 0)
                typed_count += 1
                time.sleep(0.02)

            time.sleep(0.3)

            # Independent readback verification
            observed_text = ""
            WM_GETTEXT = 0x000D
            WM_GETTEXTLENGTH = 0x000E

            def _read_hwnd(h):
                n = user32.SendMessageW(h, WM_GETTEXTLENGTH, 0, 0)
                if 0 < n < 50000:
                    buf = ctypes.create_unicode_buffer(n + 1)
                    copied = user32.SendMessageW(h, WM_GETTEXT, n + 1, ctypes.byref(buf))
                    if copied > 0:
                        return buf.value
                return ""

            # Check child controls first for document text rather than window title
            child_hwnds = []
            def _child_cb(chwnd, extra):
                child_hwnds.append(chwnd)
                return True
            user32.EnumChildWindows(target_hwnd, WNDENUMPROC(_child_cb), 0)
            for chwnd in child_hwnds:
                txt = _read_hwnd(chwnd)
                if txt:
                    observed_text = txt
                    break
            if not observed_text:
                observed_text = _read_hwnd(target_hwnd)

            observed_clean = observed_text.strip()
            expected_clean = text_to_type.strip()
            content_match = (expected_clean in observed_clean) if observed_clean else False
            obs_sha = hashlib.sha256(observed_clean.encode('utf-8')).hexdigest() if observed_clean else ""

            # Return to Digital Optimus window if requested
            if params.get("return_to_optimus", True):
                optimus_hwnd = 0
                def _opt_cb(h, extra):
                    nonlocal optimus_hwnd
                    if user32.IsWindowVisible(h):
                        l = user32.GetWindowTextLengthW(h)
                        if l > 0:
                            b = ctypes.create_unicode_buffer(l + 1)
                            user32.GetWindowTextW(h, b, l + 1)
                            if "Digital Optimus" in b.value:
                                optimus_hwnd = h
                                return False
                    return True
                user32.EnumWindows(WNDENUMPROC(_opt_cb), 0)
                if optimus_hwnd:
                    user32.SetForegroundWindow(optimus_hwnd)

            post_obs = cls.get_observation(device_id=device_id)

            # DEFECTO 2: Idempotencia de side effect parcial - registrar request si se escribieron caracteres
            if req_id and typed_count > 0:
                cls._executed_request_ids.add(req_id)

            if abort_status:
                verified_success = False
                status_str = abort_status
            elif content_match:
                verified_success = True
                status_str = "SUCCESS"
            else:
                verified_success = False
                status_str = "VERIFICATION_FAILED"

            return {
                "ok": verified_success,
                "status": status_str,
                "request_id": req_id,
                "action": action,
                "app_alias": app_alias,
                "executable": exe_path,
                "pre_observation": pre_obs,
                "post_observation": post_obs,
                "verification": {
                    "verified": verified_success,
                    "chars_typed": typed_count,
                    "expected_length": len(text_to_type),
                    "observed_length": len(observed_clean),
                    "content_match": bool(content_match),
                    "observed_sha256": obs_sha,
                    "target_hwnd": target_hwnd,
                    "pid": proc.pid,
                    "lost_foreground": lost_foreground,
                    "abort_status": abort_status,
                }
            }

        # Filesystem Hands Actions (CREATOR AUTHORIZED PC ACCESS V1)
        fs_actions = {
            "LIST_DIRECTORY", "LIST", "READ_FILE", "CREATE_FILE", "EDIT_FILE", "WRITE_FILE",
            "CREATE_DIRECTORY", "CREATE_FOLDER", "RENAME", "RENAME_FILE", "RENAME_DIRECTORY",
            "MOVE", "MOVE_FILE", "MOVE_DIRECTORY", "COPY", "COPY_FILE", "COPY_DIRECTORY",
            "DELETE_FILE", "DELETE_DIRECTORY", "DELETE", "AUTHORIZE_ROOT", "REVOKE_ROOT",
            "GET_AUTHORIZED_ROOTS"
        }
        if action in fs_actions:
            try:
                from filesystem_permission_broker import FilesystemPermissionBroker
                broker = FilesystemPermissionBroker.get_instance()
            except Exception as e:
                return {
                    "ok": False,
                    "status": "BROKER_UNAVAILABLE",
                    "error": f"FilesystemPermissionBroker unavailable: {e}",
                    "request_id": req_id,
                    "action": action
                }

            res = None
            if action in ("LIST_DIRECTORY", "LIST"):
                target_p = params.get("path") or cls._workspace_root or "."
                res = broker.list_directory(target_p)
            elif action == "READ_FILE":
                res = broker.read_file(params.get("path", ""))
            elif action in ("CREATE_FILE", "WRITE_FILE"):
                res = broker.create_file(params.get("path", ""), content=params.get("content", ""))
            elif action == "EDIT_FILE":
                res = broker.edit_file(params.get("path", ""), content=params.get("content", ""), base_sha=params.get("base_sha"))
            elif action in ("CREATE_DIRECTORY", "CREATE_FOLDER"):
                res = broker.create_directory(params.get("path", ""))
            elif action in ("RENAME", "RENAME_FILE", "RENAME_DIRECTORY"):
                res = broker.rename(params.get("source") or params.get("src", ""), params.get("target") or params.get("dst", ""))
            elif action in ("MOVE", "MOVE_FILE", "MOVE_DIRECTORY"):
                res = broker.move(params.get("source") or params.get("src", ""), params.get("target") or params.get("dst", ""))
            elif action in ("COPY", "COPY_FILE", "COPY_DIRECTORY"):
                res = broker.copy(params.get("source") or params.get("src", ""), params.get("target") or params.get("dst", ""))
            elif action in ("DELETE_FILE", "DELETE"):
                res = broker.delete_file(params.get("path", ""), approval_override=params.get("approval_override", False))
            elif action == "DELETE_DIRECTORY":
                res = broker.delete_directory(params.get("path", ""), approval_override=params.get("approval_override", False))
            elif action == "AUTHORIZE_ROOT":
                res = broker.authorize_root(params.get("path", ""), scope=params.get("scope", "PERSISTENT_PROJECT_SCOPE"))
            elif action == "REVOKE_ROOT":
                ok = broker.revoke(params.get("path", ""))
                res = {"ok": ok, "path": params.get("path", "")}
            elif action == "GET_AUTHORIZED_ROOTS":
                roots = broker.get_authorized_roots()
                res = {"ok": True, "authorized_roots": roots}

            if res and res.get("ok"):
                if req_id:
                    cls._executed_request_ids.add(req_id)
                return {
                    "ok": True,
                    "status": "SUCCESS",
                    "request_id": req_id,
                    "action": action,
                    "result": res,
                    "verification": {"verified": True, "details": res}
                }
            else:
                err_msg = res.get("error", "Filesystem action failed") if res else "Unknown error"
                return {
                    "ok": False,
                    "status": res.get("error", "FAILED") if res else "FAILED",
                    "error": err_msg,
                    "request_id": req_id,
                    "action": action,
                    "result": res,
                    "verification": {"verified": False, "reason": err_msg}
                }

        return {
            "ok": False,
            "status": "UNSUPPORTED_ACTION",
            "error": f"ACTION_INVALID_PARAMETERS: Acción '{action}' no soportada o parámetros inválidos.",
            "request_id": req_id,
            "action": action,
            "pre_observation": pre_obs,
            "verification": {"verified": False, "reason": f"Action {action} not supported"}
        }



class OptimusConversationPanel(tk.Frame):
    """
    Componente Conversacional Reutilizable para Digital Optimus.
    Soporta entrada de texto, voz (vía micrófono interno y STT con transcribe_only),
    historial con colores de roles, llamadas asíncronas no bloqueantes y aislamiento de contexto.
    """
    def __init__(
        self,
        parent,
        client,
        mission_id: str,
        request_id: str,
        question_id: Optional[str] = None,
        question_text: Optional[str] = None,
        on_draft_callback: Optional[Callable[[str], None]] = None,
        context_title: str = "Diálogo Contextual con Optimus",
        bg: str = "#ffffff",
        **kwargs
    ):
        super().__init__(parent, bg=bg, **kwargs)
        self.client = client
        self.mission_id = mission_id
        self.request_id = request_id
        self.question_id = question_id
        self.question_text = question_text
        self.on_draft_callback = on_draft_callback
        self.context_title = context_title
        self.last_optimus_reply = ""
        self._is_recording = False
        self._is_sending = False

        self._build_ui()
        self.load_history()

    def _get_context_badge_text(self) -> str:
        if self.question_id:
            disp = self.question_text or f"Pregunta {self.question_id}"
            return f"💬 Conversando sobre: {disp}"
        return f"💬 {self.context_title}"

    def _build_ui(self):
        # 1. REGLA ESTRUCTURAL DE LAYOUT: Empaquetar la barra inferior PRIMERO con side=tk.BOTTOM.
        # Esto asegura que los controles de entrada y botones NUNCA queden fuera de pantalla.
        self.bottom_frame = tk.Frame(self, bg="#f8f9fa", relief=tk.GROOVE, bd=1)
        self.bottom_frame.pack(side=tk.BOTTOM, fill=tk.X, padx=4, pady=(4, 4))

        input_header_row = tk.Frame(self.bottom_frame, bg="#f8f9fa")
        input_header_row.pack(fill=tk.X, padx=6, pady=(4, 2))

        self.input_lbl = tk.Label(
            input_header_row,
            text="Escribe tu consulta o duda (o presiona '🎙 Hablar'):",
            font=("Segoe UI", 9, "bold"),
            bg="#f8f9fa",
            fg="#212529"
        )
        self.input_lbl.pack(side=tk.LEFT)

        self.msg_entry = tk.Text(
            self.bottom_frame,
            height=3,
            font=("Segoe UI", 10),
            wrap=tk.WORD,
            bg="#ffffff",
            fg="#212529",
            relief=tk.SUNKEN,
            bd=1
        )
        self.msg_entry.pack(fill=tk.X, padx=6, pady=(0, 4))
        self.msg_entry.bind("<Control-Return>", lambda e: self.send_message())

        btn_row = tk.Frame(self.bottom_frame, bg="#f8f9fa")
        btn_row.pack(fill=tk.X, padx=6, pady=(0, 4))

        self.status_lbl = tk.Label(btn_row, text="Listo.", font=("Segoe UI", 9, "italic"), bg="#f8f9fa", fg="#6c757d")
        self.status_lbl.pack(side=tk.LEFT, padx=2)

        self.send_btn = tk.Button(
            btn_row,
            text="✉ Enviar Mensaje",
            bg="#0d6efd",
            fg="#ffffff",
            font=("Segoe UI", 9, "bold"),
            relief=tk.RAISED,
            padx=10,
            pady=3,
            command=self.send_message
        )
        self.send_btn.pack(side=tk.RIGHT, padx=3)

        self.voice_btn = tk.Button(
            btn_row,
            text="🎙 Hablar",
            bg="#198754",
            fg="#ffffff",
            font=("Segoe UI", 9, "bold"),
            relief=tk.RAISED,
            padx=10,
            pady=3,
            command=self.start_voice
        )
        self.voice_btn.pack(side=tk.RIGHT, padx=3)

        if self.on_draft_callback:
            self.draft_btn = tk.Button(
                btn_row,
                text="📋 Usar respuesta como borrador",
                bg="#e9ecef",
                fg="#212529",
                font=("Segoe UI", 8),
                relief=tk.RAISED,
                padx=6,
                pady=3,
                command=self._use_reply_as_draft
            )
            self.draft_btn.pack(side=tk.RIGHT, padx=3)

        # 2. BARRA DE CONTEXTO SUPERIOR
        self.top_bar = tk.Frame(self, bg="#e7f1ff", relief=tk.RIDGE, bd=1)
        self.top_bar.pack(side=tk.TOP, fill=tk.X, padx=4, pady=(4, 2))

        self.context_lbl = tk.Label(
            self.top_bar,
            text=self._get_context_badge_text(),
            font=("Segoe UI", 9, "bold"),
            bg="#e7f1ff",
            fg="#084298"
        )
        self.context_lbl.pack(side=tk.LEFT, padx=8, pady=3)

        # 3. VISUALIZADOR DE DIÁLOGO (ScrolledText) - Ocupa el centro con expand=True
        self.chat_txt = scrolledtext.ScrolledText(
            self,
            font=("Segoe UI", 10),
            wrap=tk.WORD,
            spacing1=3,
            spacing2=2,
            spacing3=4,
            bg="#ffffff"
        )
        self.chat_txt.pack(side=tk.TOP, fill=tk.BOTH, expand=True, padx=4, pady=2)
        self.chat_txt.tag_config("tag_creator", foreground="#0d6efd", font=("Segoe UI", 10, "bold"))
        self.chat_txt.tag_config("tag_optimus", foreground="#198754", font=("Segoe UI", 10, "bold"))
        self.chat_txt.tag_config("tag_time", foreground="#6c757d", font=("Consolas", 8))
        self.chat_txt.tag_config("tag_body", foreground="#212529", font=("Segoe UI", 10))
        self.chat_txt.tag_config("tag_system", foreground="#6c757d", font=("Segoe UI", 9, "italic"))
        self.chat_txt.config(state="disabled")

    def _append_to_chat(self, role: str, content: str, ts: str = ""):
        self.chat_txt.config(state="normal")
        time_str = ts[11:19] if len(ts) >= 19 else (ts or time.strftime("%H:%M:%S"))
        if role.upper() in ("CREATOR", "USER"):
            self.chat_txt.insert(tk.END, f"\n👤 Creador ", "tag_creator")
            self.chat_txt.insert(tk.END, f"[{time_str}]\n", "tag_time")
            self.chat_txt.insert(tk.END, f"{content}\n", "tag_body")
        elif role.upper() in ("OPTIMUS", "ASSISTANT"):
            self.chat_txt.insert(tk.END, f"\n🤖 Optimus ", "tag_optimus")
            self.chat_txt.insert(tk.END, f"[{time_str}]\n", "tag_time")
            self.chat_txt.insert(tk.END, f"{content}\n", "tag_body")
            self.last_optimus_reply = content
        else:
            self.chat_txt.insert(tk.END, f"\n[{role}] {content}\n", "tag_system")
        self.chat_txt.see(tk.END)
        self.chat_txt.config(state="disabled")

    def set_context(self, question_id: Optional[str] = None, question_text: Optional[str] = None):
        self.question_id = question_id
        self.question_text = question_text
        self.context_lbl.config(text=self._get_context_badge_text())
        self.load_history()

    def load_history(self):
        if not self.client:
            self.status_lbl.config(text="Modo sin conexión.", fg="#6c757d")
            return
        self.status_lbl.config(text="Cargando historial...", fg="#0d6efd")
        url = f"/developer/missions/{self.mission_id}/creator_request/conversation?request_id={self.request_id}"
        if self.question_id:
            url += f"&question_id={self.question_id}"

        def _bg_load():
            code, res = self.client.do_get(url)
            def _on_loaded():
                self.chat_txt.config(state="normal")
                self.chat_txt.delete("1.0", tk.END)
                self.chat_txt.config(state="disabled")
                if code == 200 and isinstance(res, dict):
                    msgs = res.get("messages", [])
                    if not msgs:
                        ctx_desc = "sobre esta pregunta" if self.question_id else "sobre esta solicitud"
                        self._append_to_chat("SYSTEM", f"Inicia una conversación aclaratoria con Optimus {ctx_desc}. Optimus responderá fundamentando su razonamiento con evidencia real.")
                    else:
                        for m in msgs:
                            self._append_to_chat(m.get("role", "SYSTEM"), m.get("content", ""), m.get("created_at", ""))
                    self.status_lbl.config(text="Listo.", fg="#198754")
                else:
                    self.status_lbl.config(text="No se pudo cargar historial previo.", fg="#dc3545")
                self.msg_entry.focus_set()
            self.after(0, _on_loaded)

        import threading
        threading.Thread(target=_bg_load, daemon=True).start()

    def set_enabled(self, enabled: bool):
        st = "normal" if enabled else "disabled"
        self.send_btn.config(state=st)
        self.voice_btn.config(state=st)
        self.msg_entry.config(state=st)

    def send_message(self):
        if self._is_sending:
            return
        txt = self.msg_entry.get("1.0", tk.END).strip()
        if not txt:
            return
        self.msg_entry.delete("1.0", tk.END)
        self._append_to_chat("CREATOR", txt, time.strftime("%H:%M:%S"))
        self._is_sending = True
        self.send_btn.config(state="disabled")
        self.status_lbl.config(text="Optimus está pensando...", fg="#0d6efd")

        payload = {
            "token": getattr(self.client, "token", None),
            "request_id": self.request_id,
            "message": txt,
        }
        if self.question_id:
            payload["question_id"] = self.question_id
            payload["question_text"] = self.question_text or ""

        def _bg_send():
            code, res = self.client.do_post(
                f"/developer/missions/{self.mission_id}/creator_request/conversation",
                payload
            )
            def _on_reply():
                self._is_sending = False
                self.send_btn.config(state="normal")
                if code == 200 and isinstance(res, dict):
                    reply = res.get("reply", "")
                    self._append_to_chat("OPTIMUS", reply, time.strftime("%H:%M:%S"))
                    self.status_lbl.config(text="Listo.", fg="#198754")
                else:
                    err = res.get("message", "Error de comunicación") if isinstance(res, dict) else str(res)
                    self._append_to_chat("SYSTEM", f"Error al consultar a Optimus: {err}")
                    self.status_lbl.config(text="Error.", fg="#dc3545")
                self.msg_entry.focus_set()
            self.after(0, _on_reply)

        import threading
        threading.Thread(target=_bg_send, daemon=True).start()

    def start_voice(self):
        if self._is_recording:
            return
        self._is_recording = True
        self.voice_btn.config(text="🎙 Grabando...", state="disabled")
        self.status_lbl.config(text="Escuchando tu voz (4s)...", fg="#0d6efd")

        def _bg_voice():
            try:
                wav_bytes = self.client.capture_microphone_wav_bytes(
                    status_callback=lambda st: self.after(0, lambda s=st: self.status_lbl.config(text=s))
                )
                if not wav_bytes:
                    self.after(0, lambda: self.status_lbl.config(text="No se capturó audio.", fg="#dc3545"))
                    return

                import base64
                audio_b64 = base64.b64encode(wav_bytes).decode("ascii")
                self.after(0, lambda: self.status_lbl.config(text="Transcribiendo audio...", fg="#0d6efd"))
                code, res = self.client.do_post(
                    "/voice_input",
                    {
                        "token": self.client.token,
                        "input_mode": "VOICE",
                        "audio_b64": audio_b64,
                        "format": "wav",
                        "transcribe_only": True,
                    }
                )
                if code == 200 and isinstance(res, dict):
                    transcription = (res.get("transcription") or "").strip()
                    if transcription:
                        def _set_text():
                            curr = self.msg_entry.get("1.0", tk.END).strip()
                            if curr:
                                self.msg_entry.insert(tk.END, " " + transcription)
                            else:
                                self.msg_entry.insert("1.0", transcription)
                            self.msg_entry.focus_set()
                            self.status_lbl.config(text="Voz transcrita. Revisa y pulsa 'Enviar'.", fg="#198754")
                        self.after(0, _set_text)
                    else:
                        self.after(0, lambda: self.status_lbl.config(text="Audio no reconocido. Intenta hablar más cerca.", fg="#e67e22"))
                else:
                    err = res.get("message", "Error STT") if isinstance(res, dict) else str(res)
                    self.after(0, lambda: self.status_lbl.config(text=f"Error STT: {err}", fg="#dc3545"))
            except Exception as e:
                self.after(0, lambda: self.status_lbl.config(text=f"Error audio: {e}", fg="#dc3545"))
            finally:
                self._is_recording = False
                self.after(0, lambda: self.voice_btn.config(text="🎙 Hablar", state="normal"))

        import threading
        threading.Thread(target=_bg_voice, daemon=True).start()

    def _use_reply_as_draft(self):
        if self.on_draft_callback and self.last_optimus_reply:
            self.on_draft_callback(self.last_optimus_reply)


class DigitalOptimusClient(tk.Tk):
    def __init__(self, config: Optional[Dict[str, Any]] = None):
        super().__init__()
        self.title(f"Digital Optimus — Creator Workbench ({CURRENT_VERSION})")
        self.geometry("1280x800")
        self.minsize(980, 600)
        
        self.config = dict(config) if config is not None else self.load_config()

        # REQUISITO 1, 2, 3, 4: Cargar authorized_role persistido de la configuración local.
        # Si es exactamente CREATOR: authorized_role = "CREATOR"
        # Si es CLIENT, inexistente, inválido o corrupto: fail-closed como CLIENT.
        persisted_role = self.config.get("authorized_role")
        if isinstance(persisted_role, str) and persisted_role.strip().upper() == "CREATOR":
            self.authorized_role = "CREATOR"
        else:
            self.authorized_role = "CLIENT"

        self._active_ui_role = None
        self.client_role_window = None
        self.client_role_view = None
        self.workbench = None
        self.device_id = self.config.get("device_id", str(uuid.uuid4()))
        self.config["device_id"] = self.device_id
        self.active_conversation_id = self.config.get("active_conversation_id")
        self.save_config()
        
        self.token = self.config.get("token")
        self.pairing_secret = self.config.get("pairing_secret")
        self.server_url = self.config.get("server_url")
        self.pinned_fingerprint = self.config.get("pinned_fingerprint")
        self._recording = False
        self.voice_diagnostics = bool(self.config.get("voice_diagnostics", False))
        import queue
        self._ui_queue = queue.Queue()
        self._process_ui_queue()
        
        import threading
        self._pairing_lock = threading.Lock()
        self._is_pairing = False
        self._retry_delay = 3
        self._retry_timer = None
        self._connection_state = "DISCONNECTED"

        self.setup_ui()
        self.update_status("Disconnected")

        # Test mode auto-run
        self.is_test = len(sys.argv) > 1 and sys.argv[1] == "--test"
        
        if not self.server_url or not self.pinned_fingerprint:
            self.after(100, self.first_run_config)
        else:
            self.after(10, self.pair_device)
        self.after(200, lambda: ensure_windows_app_shortcuts(self.diag_log))

    def load_config(self):
        cfg_path = os.environ.get("DIGITAL_OPTIMUS_CONFIG_FILE") or get_config_file()
        if os.environ.get("DIGITAL_OPTIMUS_TEST_MODE") == "1" and not os.environ.get("DIGITAL_OPTIMUS_CONFIG_FILE"):
            return {}
        data = {}
        had_plaintext_secrets = False
        if os.path.exists(cfg_path):
            try:
                with open(cfg_path, "r", encoding="utf-8") as f:
                    loaded = json.load(f)
                    if isinstance(loaded, dict):
                        data = loaded
            except Exception:
                data = {}
        dpapi_path = get_device_token_dpapi_file(cfg_path)
        if os.path.exists(dpapi_path):
            try:
                with open(dpapi_path, "rb") as f_sec:
                    enc_b64 = f_sec.read().strip()
                if enc_b64:
                    raw_json = _dpapi_unprotect_bytes(base64.b64decode(enc_b64))
                    sec_dict = json.loads(raw_json.decode("utf-8"))
                    if isinstance(sec_dict, dict):
                        if sec_dict.get("token"):
                            data["token"] = sec_dict["token"]
                        if sec_dict.get("pairing_secret"):
                            data["pairing_secret"] = sec_dict["pairing_secret"]
            except Exception:
                pass
        if "token" in data or "pairing_secret" in data:
            # Ensure any legacy plaintext token in config.json is migrated to DPAPI on disk
            try:
                with open(cfg_path, "r", encoding="utf-8") as f_chk:
                    raw_disk = json.load(f_chk)
                if isinstance(raw_disk, dict) and ("token" in raw_disk or "pairing_secret" in raw_disk):
                    had_plaintext_secrets = True
            except Exception:
                pass
        if had_plaintext_secrets:
            self.config = data
            self.save_config()
        return data

    def save_config(self):
        cfg_path = os.environ.get("DIGITAL_OPTIMUS_CONFIG_FILE") or get_config_file()
        if os.environ.get("DIGITAL_OPTIMUS_TEST_MODE") == "1" and not os.environ.get("DIGITAL_OPTIMUS_CONFIG_FILE"):
            return
        try:
            os.makedirs(os.path.dirname(os.path.abspath(cfg_path)), exist_ok=True)
            sec_payload = {}
            if self.config.get("token"):
                sec_payload["token"] = self.config["token"]
            if self.config.get("pairing_secret"):
                sec_payload["pairing_secret"] = self.config["pairing_secret"]
            dpapi_path = get_device_token_dpapi_file(cfg_path)
            if sec_payload:
                enc_bytes = _dpapi_protect_bytes(json.dumps(sec_payload).encode("utf-8"))
                with open(dpapi_path, "wb") as f_sec:
                    f_sec.write(base64.b64encode(enc_bytes))
            disk_cfg = {
                k: v for k, v in self.config.items() if k not in ("token", "pairing_secret")
            }
            with open(cfg_path, "w", encoding="utf-8") as f:
                json.dump(disk_cfg, f, indent=2)
        except Exception:
            pass

    def setup_ui(self):
        # Creator Workbench is the primary integrated development & observability interface.
        # Root Tk window is withdrawn so that all Workbench windows are independent peer Toplevels.
        self.withdraw()
        self.open_windows = []

        # Shared Project Registry & Session Store across all windows
        self.project_registry = ProjectRegistry() if ProjectRegistry else None
        self.session_store = ProjectSessionStore() if ProjectSessionStore else None

        # Route initial UI based on authorized role (fail-closed as CLIENT)
        self._apply_authorized_role_ui()

    def _apply_authorized_role_ui(self):
        role = str(getattr(self, "authorized_role", "CLIENT") or "CLIENT").strip().upper()
        if role not in ("CREATOR", "CLIENT"):
            role = "CLIENT"

        if self._active_ui_role == role:
            if role == "CREATOR" and self.workbench and hasattr(self.workbench, "window_widget") and self.workbench.window_widget.winfo_exists():
                return
            if role == "CLIENT" and self.client_role_window and self.client_role_window.winfo_exists():
                return

        if role == "CREATOR":
            # Close client role window if active
            if hasattr(self, "client_role_window") and self.client_role_window:
                try:
                    if self.client_role_window.winfo_exists():
                        self.client_role_window.destroy()
                except Exception:
                    pass
                self.client_role_window = None
                self.client_role_view = None

            # Open CreatorWorkbench
            wb = self.create_new_window(clean=False)
            self.workbench = wb

            # Wire backward compatibility attributes
            if wb:
                self.status_var = wb.status_var
                self.chat_area = wb.chat_history
                self.entry = wb.chat_input
                self.input_var = tk.StringVar()
                self.pause_btn = wb.btn_pause_resume
                self.kill_btn = wb.btn_kill_switch
                self.cancel_btn = tk.Button(self)
                self.conv_combo_var = tk.StringVar()
                self.conv_combo = ttk.Combobox(self, textvariable=self.conv_combo_var)
                self.click_test_counter = 0
                self.click_test_target_activated = False
                self.click_test_btn = tk.Button(self)
                self.key_test_counter = 0
                self.key_test_target_activated = False
                self.key_test_btn = tk.Button(self)

            self._active_ui_role = "CREATOR"

        elif role == "CLIENT":
            # Close any open CreatorWorkbench windows
            if hasattr(self, "open_windows") and self.open_windows:
                for wb in list(self.open_windows):
                    try:
                        top = getattr(wb, "window_widget", None)
                        if top and top.winfo_exists():
                            top.destroy()
                    except Exception:
                        pass
                self.open_windows.clear()
            self.workbench = None

            # Open restricted Client window
            top = tk.Toplevel(self)
            top.geometry("1000x700")
            top.minsize(760, 500)
            top.title("Digital Optimus — Client")

            if ClientRoleView:
                view = ClientRoleView(top, self)
                view.pack(fill=tk.BOTH, expand=True)
                self.client_role_window = top
                self.client_role_view = view

                self.status_var = view.status_var
                self.chat_area = view.chat_history
                self.entry = view.chat_input
            else:
                self.status_var = tk.StringVar(value="Modo Cliente")
                self.chat_area = None
                self.entry = None

            # Dummy offscreen widgets for backward compatibility
            self.pause_btn = tk.Button(self)
            self.kill_btn = tk.Button(self)
            self.cancel_btn = tk.Button(self)
            self.conv_combo_var = tk.StringVar()
            self.conv_combo = ttk.Combobox(self, textvariable=self.conv_combo_var)
            self.click_test_btn = tk.Button(self)
            self.key_test_btn = tk.Button(self)

            top.protocol("WM_DELETE_WINDOW", self._on_app_close)
            self._active_ui_role = "CLIENT"

    def create_new_window(self, clean: bool = True):
        if str(getattr(self, "authorized_role", "CLIENT")).upper() != "CREATOR":
            self.log("[ROLE] Creator Workbench bloqueado: el dispositivo no posee rol CREATOR.")
            return None
        top = tk.Toplevel(self)
        top.geometry("1280x800")
        top.title(f"Digital Optimus — Creator Workbench ({CURRENT_VERSION})")
        wb = CreatorWorkbench(
            top,
            self,
            clean=clean,
            project_registry=getattr(self, "project_registry", None),
            session_store=getattr(self, "session_store", None)
        )
        wb.window_widget = top
        if not hasattr(self, "open_windows"):
            self.open_windows = []
        self.open_windows.append(wb)
        self.workbench = wb
        top.protocol("WM_DELETE_WINDOW", lambda: self.close_window(wb, top))
        return wb

    def close_window(self, wb, top_widget=None):
        """Cierra una ventana física de forma aislada sin cerrar las otras (Secciones 3, 4, 5, 6)."""
        try:
            if hasattr(wb, "close_window"):
                wb.close_window()
            else:
                wb.save_all_dirty_files()
        except Exception:
            pass

        if hasattr(self, "open_windows") and wb in self.open_windows:
            self.open_windows.remove(wb)

        if top_widget and top_widget.winfo_exists():
            top_widget.destroy()

        if self.open_windows:
            self.workbench = self.open_windows[-1]
        else:
            # ÚLTIMA VENTANA CERRADA: APPLICATION_EXIT
            self._on_app_close()

    def _on_subwindow_close(self, top_widget, wb):
        self.close_window(wb, top_widget)

    def _on_app_close(self):
        try:
            if hasattr(self, "open_windows"):
                for wb in list(self.open_windows):
                    try:
                        if hasattr(wb, "close_window"):
                            wb.close_window()
                        else:
                            wb.save_all_dirty_files()
                    except Exception:
                        pass
            from .workbench import ProjectRegistry
            ProjectRegistry().record_shutdown(clean=True)
        except Exception:
            pass
        self.destroy()

    def toggle_pause(self):
        if LocalActionExecutor.is_paused():
            LocalActionExecutor.resume()
            self._apply_pause_ui_state(False)
            self.log("[Seguridad Local] Acciones locales REANUDADAS.")
        else:
            LocalActionExecutor.pause()
            self._apply_pause_ui_state(True)
            self.log("[Seguridad Local] Acciones locales PAUSADAS.")

    def _apply_pause_ui_state(self, paused: bool):
        if hasattr(self, "workbench") and self.workbench:
            if paused:
                self.workbench.btn_pause_resume.config(text="▶ Reanudar", bg="#98c379", fg="#212529")
                self.workbench.status_var.set("🟡 PAUSADO" + (f" | Solicitudes: {self.workbench.pending_requests_count}" if self.workbench.pending_requests_count > 0 else ""))
                self.workbench.lbl_status.configure(fg="#e5c07b")
            else:
                self.workbench.btn_pause_resume.config(text="⏸ Pausar", bg="#d19a66", fg="#ffffff")
                self.workbench.status_var.set("🟢 EJECUTANDO" + (f" | Solicitudes: {self.workbench.pending_requests_count}" if self.workbench.pending_requests_count > 0 else ""))
                self.workbench.lbl_status.configure(fg="#98c379")

        if hasattr(self, "pause_btn") and self.pause_btn:
            try:
                if paused:
                    self.pause_btn.config(text="▶ Reanudar", bg="#d1e7dd")
                    self.update_status("PAUSADO")
                else:
                    self.pause_btn.config(text="⏸ Pausar", bg="#fff3cd")
                    if not LocalActionExecutor.is_kill_switch_active():
                        self.update_status("Listo")
            except Exception:
                pass

    def trigger_cancel(self):
        LocalActionExecutor.cancel()
        self.log("[Seguridad Local] Señal de CANCELACIÓN activada para la acción actual/pendiente.")

    def toggle_kill_switch(self):
        if LocalActionExecutor.is_kill_switch_active():
            LocalActionExecutor.reset_control()
            self._apply_kill_ui_state(False)
            self.log("[Seguridad Local] Sistema restablecido. Acciones locales autorizadas nuevamente.")
        else:
            LocalActionExecutor.kill_switch()
            self._apply_kill_ui_state(True)
            self.log("[KILL SWITCH] 🛑 Parada de emergencia ejecutada localmente. Todas las acciones de escritorio bloqueadas.")

    def _apply_kill_ui_state(self, killed: bool):
        if killed:
            self.kill_btn.config(text="🔄 Restablecer Sistema", bg="#cce5ff", fg="#004085")
            self.update_status("KILL SWITCH ACTIVO (Acciones Bloqueadas)")
        else:
            self.kill_btn.config(text="🛑 Parada Segura (Kill Switch)", bg="#f8d7da", fg="#721c24")
            self.pause_btn.config(text="⏸ Pausar", bg="#fff3cd")
            self.update_status("Listo")

    def _on_click_test_target_pressed(self):
        self.click_test_counter += 1
        self.click_test_target_activated = True
        self.click_test_btn.config(text=f"Prueba de clic ({self.click_test_counter})", bg="#d4edda")
        self.log(f"[Diagnóstico] Target seguro presionado. Contador: {self.click_test_counter}")

    def _on_key_test_target_pressed(self):
        self.key_test_counter += 1
        self.key_test_target_activated = True
        self.key_test_btn.config(text=f"Prueba de tecla ({self.key_test_counter})", bg="#d4edda")
        self.log(f"[Diagnóstico] Target seguro de tecla presionado. Contador: {self.key_test_counter}")

    def _register_client_safe_target(self):
        def _get_rect():
            try:
                self.update_idletasks()
                rx = self.click_test_btn.winfo_rootx()
                ry = self.click_test_btn.winfo_rooty()
                w = self.click_test_btn.winfo_width()
                h = self.click_test_btn.winfo_height()
                if w > 1 and h > 1:
                    return {"x": rx, "y": ry, "width": w, "height": h}
            except Exception:
                pass
            return None

        def _get_state():
            return {
                "counter": self.click_test_counter,
                "activated": self.click_test_target_activated
            }

        def _trigger():
            self._on_click_test_target_pressed()

        def _get_key_rect():
            try:
                self.update_idletasks()
                rx = self.key_test_btn.winfo_rootx()
                ry = self.key_test_btn.winfo_rooty()
                w = self.key_test_btn.winfo_width()
                h = self.key_test_btn.winfo_height()
                if w > 1 and h > 1:
                    return {"x": rx, "y": ry, "width": w, "height": h}
            except Exception:
                pass
            return None

        def _get_key_state():
            return {
                "counter": self.key_test_counter,
                "activated": self.key_test_target_activated
            }

        def _trigger_key():
            self._on_key_test_target_pressed()

        def _focus_key():
            try:
                self.key_test_btn.focus_set()
            except Exception:
                pass

        LocalActionExecutor.register_safe_target("SAFE_CLICK_TARGET", _get_rect, _get_state, _trigger)
        LocalActionExecutor.register_safe_target("SAFE_KEY_TARGET", _get_key_rect, _get_key_state, _trigger_key, _focus_key)

    def log(self, text):
        if hasattr(self, "workbench") and self.workbench is not None and hasattr(self.workbench, "add_system_log"):
            self.workbench.add_system_log(text)
        elif hasattr(self, "chat_area") and self.chat_area:
            self.chat_area.config(state='normal')
            self.chat_area.insert(tk.END, text + "\n")
            self.chat_area.config(state='disabled')
            self.chat_area.yview(tk.END)

    def diag_log(self, text):
        if getattr(self, "voice_diagnostics", False):
            self.after(0, lambda t=text: self.log(t))

    def update_status(self, text):
        self.status_var.set(f"Status: {text}")
        
    def fetch_server_certificate_fingerprint(self, host, port, timeout=5):
        """
        Fetches the SHA-256 fingerprint of the server certificate.
        Returns:
            (fingerprint_hex, None) on success.
            (None, error_str) on network failure or TLS handshake failure.
        """
        import socket
        context = ssl._create_unverified_context()
        try:
            with socket.create_connection((host, int(port)), timeout=timeout) as sock:
                with context.wrap_socket(sock, server_hostname=host) as ssock:
                    cert = ssock.getpeercert(binary_form=True)
                    if not cert:
                        return None, "No peer certificate presented by server"
                    self._last_verified_cert_pem = ssl.DER_cert_to_PEM_cert(cert)
                    return hashlib.sha256(cert).hexdigest().lower(), None
        except (socket.timeout, TimeoutError) as e:
            return None, f"Connection timed out after {timeout}s: {e}"
        except ConnectionRefusedError as e:
            return None, f"Connection refused on port {port}: {e}"
        except (socket.gaierror, OSError) as e:
            return None, f"Network/socket error connecting to {host}:{port}: {e}"
        except ssl.SSLError as e:
            return None, f"TLS handshake error: {e}"
        except Exception as e:
            return None, f"Unexpected error connecting to {host}:{port}: {e}"

    def get_server_fingerprint(self, host, port):
        # We manually fetch the cert to get its fingerprint
        fp, err = self.fetch_server_certificate_fingerprint(host, port)
        if fp:
            return fp
        return f"ERROR: {err}"

    def first_run_config(self):
        if self.is_test:
            host = "127.0.0.1"
            port = "8443"
        else:
            host = simpledialog.askstring("Brain Host", "Enter Brain Host IP:", initialvalue="127.0.0.1")
            if not host: self.destroy(); return
            port = simpledialog.askstring("Brain Host Port", "Enter TLS Port:", initialvalue="8443")
            if not port: self.destroy(); return
        
        self.log("Fetching certificate...")
        fingerprint = self.get_server_fingerprint(host, port)
        
        if len(fingerprint) != 64:
            messagebox.showerror("Connection Error", f"Could not connect: {fingerprint}")
            self.destroy()
            return
            
        if self.is_test:
            confirm = True
        else:
            confirm = messagebox.askyesno("Verify Identity", f"Server Fingerprint:\n{fingerprint}\n\nDo you trust this Brain Host?")
            
        if confirm:
            self.config["server_url"] = f"https://{host}:{port}"
            self.config["pinned_fingerprint"] = fingerprint
            self.server_url = self.config["server_url"]
            self.pinned_fingerprint = fingerprint
            self.save_config()
            self.pair_device()
        else:
            self.destroy()

    def get_secure_context(self):
        # Verify the peer certificate SHA-256 fingerprint matches the pinned fingerprint,
        # and then return a verified SSLContext anchored to that pinned certificate (CERT_REQUIRED).
        # CRITICAL: A network timeout or unreachable host raises BrainUnreachableError, NOT CertificateMismatchError!
        host = self.server_url.split("://")[1].split(":")[0]
        port = self.server_url.split(":")[2]
        
        current_fingerprint, err = self.fetch_server_certificate_fingerprint(host, port, timeout=5)
        if err or not current_fingerprint:
            raise BrainUnreachableError(f"Brain host unreachable at {host}:{port} - {err}")
        
        if len(current_fingerprint) != 64:
            raise BrainUnreachableError(f"Invalid certificate response from {host}:{port}")
            
        if current_fingerprint.lower() != self.pinned_fingerprint.lower():
            raise CertificateMismatchError(self.pinned_fingerprint, current_fingerprint)

        pem_cert = getattr(self, "_last_verified_cert_pem", None)
        if pem_cert:
            ctx = ssl.create_default_context(cadata=pem_cert)
            ctx.verify_mode = ssl.CERT_REQUIRED
            # Certificate SAN is DNS:localhost / CN=DigitalOptimusBrainHost; when connecting via IP literal
            # 127.0.0.1, cryptographic CA + SHA-256 DER pinning is enforced via CERT_REQUIRED.
            ctx.check_hostname = host.lower() in ("localhost", "digitaloptimusbrainhost")
            return ctx
        return ssl._create_unverified_context()

    def check_for_updates(self):
        if not self.token: return
        self.update_status("Checking for updates...")
        import threading
        threading.Thread(target=self._update_check_thread, daemon=True).start()

    def _update_check_thread(self):
        try:
            req = urllib.request.Request(self.server_url + "/client/version", headers={'Authorization': f'Bearer {self.token}'})
            ctx = self.get_secure_context()
            with urllib.request.urlopen(req, context=ctx, timeout=5) as res:
                if res.getcode() == 200:
                    vinfo = json.loads(res.read().decode('utf-8'))
                    if vinfo.get("version") != CURRENT_VERSION:
                        self.after(0, lambda: self.log(f"Update available: {vinfo.get('version')}"))
                        import threading
                        threading.Thread(target=self._download_and_apply_update, args=(vinfo,), daemon=True).start()
                    else:
                        self.after(0, lambda: self.update_status("Listo"))
        except urllib.error.HTTPError as e:
            if e.code == 410:
                self.after(0, lambda: self.update_status("Listo"))
            else:
                self.after(0, lambda: self.log(f"Update check failed: {e}"))
                self.after(0, lambda: self.update_status("Listo"))
        except Exception as e:
            self.after(0, lambda: self.log(f"Update check failed: {e}"))
            self.after(0, lambda: self.update_status("Listo"))

    def _download_and_apply_update(self, vinfo):
        self.after(0, lambda: self.update_status("Downloading update..."))
        try:
            req = urllib.request.Request(self.server_url + "/client/download", headers={'Authorization': f'Bearer {self.token}'})
            ctx = self.get_secure_context()
            with urllib.request.urlopen(req, context=ctx, timeout=30) as res:
                if res.getcode() == 200:
                    exe_data = res.read()
                    
                    if len(exe_data) != vinfo.get("size"):
                        raise Exception("Size mismatch")
                        
                    if hashlib.sha256(exe_data).hexdigest() != vinfo.get("sha256"):
                        raise Exception("SHA256 mismatch")
                        
                    local_app_data = os.environ.get('LOCALAPPDATA', os.path.expanduser('~\\AppData\\Local'))
                    update_dir = os.path.join(local_app_data, 'DigitalOptimus', 'updates')
                    os.makedirs(update_dir, exist_ok=True)
                    dl_path = os.path.join(update_dir, 'DigitalOptimusClient_new.exe')
                    
                    with open(dl_path, 'wb') as f:
                        f.write(exe_data)
                        
                    self.after(0, lambda: self.log("Update downloaded and verified. Applying..."))
                    self._apply_update(dl_path, vinfo)
        except Exception as e:
            self.after(0, lambda: self.log(f"Download failed: {e}"))
            self.after(0, lambda: self.update_status("Update failed"))

    def _apply_update(self, new_exe_path, vinfo):
        import subprocess
        import sys, os
        import datetime
        
        current_exe = os.path.abspath(sys.executable if sys.executable.endswith(".exe") else sys.argv[0])
        backup_exe = current_exe + ".bak"
        new_exe = os.path.abspath(new_exe_path)
        current_pid = os.getpid()
        expected_size = vinfo.get("size", 0)
        expected_sha256 = str(vinfo.get("sha256", "")).lower()
        
        update_dir = os.path.dirname(new_exe)
        updater_ps1 = os.path.join(update_dir, "updater.ps1")
        launcher_ps1 = os.path.join(update_dir, "launcher.ps1")
        updater_log = os.path.join(update_dir, "updater.log")
        
        ps1_content = f'''$OldPid = {current_pid}
$CurrentExe = @'
{current_exe}
'@
$NewExe = @'
{new_exe}
'@
$BackupExe = @'
{backup_exe}
'@
$LauncherPs1 = @'
{launcher_ps1}
'@
$LogFile = @'
{updater_log}
'@
$ExpectedSize = {expected_size}
$ExpectedSha256 = '{expected_sha256}'.ToLowerInvariant()
$TimeoutSeconds = 30
$ErrorActionPreference = 'Stop'

function Write-Log([string]$msg) {{
    $ts = [DateTime]::UtcNow.ToString("o")
    Add-Content -LiteralPath $LogFile -Value "[$ts] $msg"
}}

Write-Log "[STAGE1]"
Write-Log "STAGE1_STARTED"
Write-Log "OLD_PID=$OldPid"

Write-Log "OLD_PID_WAIT_STARTED"
$sw = [System.Diagnostics.Stopwatch]::StartNew()
while ($sw.Elapsed.TotalSeconds -lt $TimeoutSeconds) {{
    $proc = Get-Process -Id $OldPid -ErrorAction SilentlyContinue
    if (-not $proc -or $proc.HasExited) {{
        break
    }}
    Start-Sleep -Milliseconds 250
}}

$proc = Get-Process -Id $OldPid -ErrorAction SilentlyContinue
if ($proc -and -not $proc.HasExited) {{
    Write-Log "ERROR=OldPid $OldPid failed to exit within timeout"
    exit 2
}}
Write-Log "OLD_PID_WAIT_COMPLETED"

Write-Log "PROCESS_TREE_WAIT_STARTED"
$swTree = [System.Diagnostics.Stopwatch]::StartNew()
$lastCount = -1
while ($swTree.Elapsed.TotalSeconds -lt $TimeoutSeconds) {{
    $runningProcs = @(Get-CimInstance Win32_Process -ErrorAction SilentlyContinue | Where-Object {{
        $_.ExecutablePath -and ($_.ExecutablePath.Trim().ToLowerInvariant() -eq $CurrentExe.Trim().ToLowerInvariant())
    }})
    $count = $runningProcs.Count
    if ($count -ne $lastCount) {{
        Write-Log "PROCESS_TREE_ACTIVE_COUNT=$count"
        $lastCount = $count
    }}
    if ($count -eq 0) {{
        break
    }}
    Start-Sleep -Milliseconds 250
}}

$runningProcs = @(Get-CimInstance Win32_Process -ErrorAction SilentlyContinue | Where-Object {{
    $_.ExecutablePath -and ($_.ExecutablePath.Trim().ToLowerInvariant() -eq $CurrentExe.Trim().ToLowerInvariant())
}})
if ($runningProcs.Count -gt 0) {{
    Write-Log "ERROR=Processes still holding CurrentExe after timeout: count=$($runningProcs.Count)"
    exit 2
}}
Write-Log "PROCESS_TREE_WAIT_COMPLETED"

Start-Sleep -Milliseconds 500
Write-Log "FILE_HANDLE_RELEASE_GRACE_COMPLETED"

$srcExists = Test-Path -LiteralPath $NewExe
Write-Log "SOURCE_STAGING_EXISTS=$srcExists"
if (-not $srcExists) {{
    Write-Log "ERROR=NewExe not found at $NewExe"
    exit 1
}}
$srcSize = (Get-Item -LiteralPath $NewExe).Length
$srcSha = (Get-FileHash -LiteralPath $NewExe -Algorithm SHA256).Hash.ToLowerInvariant()
Write-Log "SOURCE_STAGING_SIZE=$srcSize"
Write-Log "SOURCE_STAGING_SHA=$srcSha"

Write-Log "BACKUP_STARTED"
if (Test-Path -LiteralPath $CurrentExe) {{
    Copy-Item -LiteralPath $CurrentExe -Destination $BackupExe -Force -ErrorAction Stop
}}
if (Test-Path -LiteralPath $BackupExe) {{
    $bakSize = (Get-Item -LiteralPath $BackupExe).Length
    $bakSha = (Get-FileHash -LiteralPath $BackupExe -Algorithm SHA256).Hash.ToLowerInvariant()
    Write-Log "BACKUP_COMPLETED"
    Write-Log "BACKUP_SIZE=$bakSize"
    Write-Log "BACKUP_SHA=$bakSha"
}} else {{
    Write-Log "ERROR=Backup creation failed"
    exit 1
}}

Write-Log "REPLACEMENT_STARTED"
$replaceSucceeded = $false
$maxAttempts = 5
for ($attempt = 1; $attempt -le $maxAttempts; $attempt++) {{
    Write-Log "REPLACEMENT_ATTEMPT=$attempt"
    try {{
        Copy-Item -LiteralPath $NewExe -Destination $CurrentExe -Force -ErrorAction Stop
        Write-Log "REPLACEMENT_COPY_SUCCEEDED=$attempt"
        $replaceSucceeded = $true
        break
    }} catch {{
        Write-Log "REPLACEMENT_ATTEMPT_FAILED=$attempt"
        Write-Log "ERROR=$($_.Exception.Message)"
        if ($attempt -lt $maxAttempts) {{
            Start-Sleep -Milliseconds 500
        }}
    }}
}}

if (-not $replaceSucceeded) {{
    Write-Log "REPLACEMENT_ALL_ATTEMPTS_FAILED"
    Write-Log "ROLLBACK_REQUIRED=TRUE"
    if (Test-Path -LiteralPath $BackupExe) {{
        Copy-Item -LiteralPath $BackupExe -Destination $CurrentExe -Force -ErrorAction SilentlyContinue
        Write-Log "ROLLBACK_RESTORED_BACKUP"
    }}
    exit 3
}}

Write-Log "REPLACEMENT_COMPLETED"

$destExists = Test-Path -LiteralPath $CurrentExe
$actualSize = if ($destExists) {{ (Get-Item -LiteralPath $CurrentExe).Length }} else {{ -1 }}
$actualSha256 = if ($destExists) {{ (Get-FileHash -LiteralPath $CurrentExe -Algorithm SHA256).Hash.ToLowerInvariant() }} else {{ '' }}

Write-Log "EXPECTED_SIZE=$ExpectedSize"
Write-Log "ACTUAL_SIZE=$actualSize"
Write-Log "EXPECTED_SHA=$ExpectedSha256"
Write-Log "ACTUAL_SHA=$actualSha256"

$sizeMatches = ($actualSize -eq $ExpectedSize)
$shaMatches = ($actualSha256 -eq $ExpectedSha256)

if ($sizeMatches) {{
    Write-Log "DESTINATION_SIZE_VERIFIED=TRUE"
}} else {{
    Write-Log "DESTINATION_SIZE_VERIFIED=FALSE"
}}

if ($shaMatches) {{
    Write-Log "DESTINATION_SHA_VERIFIED=TRUE"
}} else {{
    Write-Log "DESTINATION_SHA_VERIFIED=FALSE"
}}

if (-not $destExists -or -not $sizeMatches -or -not $shaMatches) {{
    Write-Log "ROLLBACK_REQUIRED=TRUE"
    Write-Log "ERROR=Destination verification failed: destExists=$destExists sizeMatches=$sizeMatches shaMatches=$shaMatches"
    if (Test-Path -LiteralPath $BackupExe) {{
        Copy-Item -LiteralPath $BackupExe -Destination $CurrentExe -Force -ErrorAction SilentlyContinue
        Write-Log "ROLLBACK_RESTORED_BACKUP"
    }}
    exit 3
}}

Write-Log "STAGE2_LAUNCH_ATTEMPT=TRUE"
$updaterPid = $PID
$launcherArgs = '-NoProfile -ExecutionPolicy Bypass -File "' + $LauncherPs1 + '" -Stage1Pid ' + $updaterPid
$stage2Proc = Start-Process -FilePath "powershell.exe" -ArgumentList $launcherArgs -PassThru
if ($stage2Proc) {{
    Write-Log "STAGE2_PROCESS_CREATED=TRUE"
    Write-Log "STAGE2_PID=$($stage2Proc.Id)"
}} else {{
    Write-Log "STAGE2_PROCESS_CREATED=FALSE"
}}
Write-Log "STAGE1_COMPLETED"
exit 0
'''

        launcher_content = f'''param([int]$Stage1Pid)

$CurrentExe = @'
{current_exe}
'@
$UpdateDir = @'
{update_dir}
'@
$LogFile = @'
{updater_log}
'@

function Write-Log([string]$msg) {{
    $ts = [DateTime]::UtcNow.ToString("o")
    Add-Content -LiteralPath $LogFile -Value "[$ts] $msg"
}}

Write-Log "[STAGE2]"
Write-Log "STAGE2_STARTED"
Write-Log "STAGE1_PID=$Stage1Pid"

Write-Log "STAGE1_WAIT_STARTED"
$sw = [System.Diagnostics.Stopwatch]::StartNew()
while ($sw.Elapsed.TotalSeconds -lt 15) {{
    $proc = Get-Process -Id $Stage1Pid -ErrorAction SilentlyContinue
    if (-not $proc -or $proc.HasExited) {{
        break
    }}
    Start-Sleep -Milliseconds 200
}}
Write-Log "STAGE1_WAIT_COMPLETED"

Start-Sleep -Seconds 1

Write-Log "PYI_ENV_CLEANUP_STARTED"
$cleanedVars = @()
Get-ChildItem Env: | Where-Object {{ $_.Name -match '^(_MEIPASS|_PYI|PYTHON|TCL|TK)' }} | ForEach-Object {{
    $cleanedVars += $_.Name
    Remove-Item -LiteralPath "Env:\$($_.Name)" -ErrorAction SilentlyContinue
}}
$cleanedList = if ($cleanedVars) {{ ($cleanedVars -join ",") }} else {{ "NONE" }}
Write-Log "PYI_ENV_CLEANUP_COMPLETED: $cleanedList"

$userPath = [System.Environment]::GetEnvironmentVariable("PATH", "User")
$sysPath = [System.Environment]::GetEnvironmentVariable("PATH", "Machine")
$env:PATH = "$userPath;$sysPath"
Write-Log "PATH_REBUILT=TRUE"

$env:PYINSTALLER_RESET_ENVIRONMENT = '1'
Write-Log "RESET_ENVIRONMENT_SET=TRUE"

$exeDir = Split-Path -Parent $CurrentExe
Write-Log "FINAL_EXE=$CurrentExe"
Write-Log "FINAL_WORKING_DIRECTORY=$exeDir"

$finalExists = Test-Path -LiteralPath $CurrentExe
Write-Log "FINAL_EXE_EXISTS=$finalExists"
if ($finalExists) {{
    $finalSize = (Get-Item -LiteralPath $CurrentExe).Length
    $finalSha = (Get-FileHash -LiteralPath $CurrentExe -Algorithm SHA256).Hash.ToLowerInvariant()
    Write-Log "FINAL_EXE_SIZE=$finalSize"
    Write-Log "FINAL_EXE_SHA=$finalSha"
}}

Write-Log "START_PROCESS_ATTEMPT=TRUE"
try {{
    $newProc = Start-Process -FilePath $CurrentExe -WorkingDirectory $exeDir -PassThru
    if ($newProc) {{
        Write-Log "START_PROCESS_CREATED=TRUE"
        Write-Log "NEW_APP_PID=$($newProc.Id)"
    }} else {{
        Write-Log "START_PROCESS_CREATED=FALSE"
    }}
}} catch {{
    Write-Log "START_PROCESS_CREATED=FALSE"
    Write-Log "ERROR=$($_.Exception.Message)"
}}

Write-Log "STAGE2_COMPLETED"

Start-Sleep -Milliseconds 500
Remove-Item -LiteralPath (Join-Path $UpdateDir "updater.bat") -Force -ErrorAction SilentlyContinue
Remove-Item -LiteralPath (Join-Path $UpdateDir "updater.ps1") -Force -ErrorAction SilentlyContinue
Remove-Item -LiteralPath (Join-Path $UpdateDir "launcher.ps1") -Force -ErrorAction SilentlyContinue
exit 0
'''

        with open(updater_ps1, "w", encoding="utf-8") as f:
            f.write(ps1_content)

        with open(launcher_ps1, "w", encoding="utf-8") as f:
            f.write(launcher_content)

        now_iso = datetime.datetime.utcnow().isoformat() + "Z"
        py_log_entry = (
            f"[{now_iso}] [PYTHON]\n"
            f"[{now_iso}] TIMESTAMP={now_iso}\n"
            f"[{now_iso}] CURRENT_VERSION={CURRENT_VERSION}\n"
            f"[{now_iso}] CURRENT_EXE={current_exe}\n"
            f"[{now_iso}] CURRENT_PID={current_pid}\n"
            f"[{now_iso}] NEW_EXE={new_exe}\n"
            f"[{now_iso}] EXPECTED_SIZE={expected_size}\n"
            f"[{now_iso}] EXPECTED_SHA={expected_sha256}\n"
            f"[{now_iso}] UPDATER_PS1={updater_ps1}\n"
            f"[{now_iso}] LAUNCHER_PS1={launcher_ps1}\n"
            f"[{now_iso}] POWERSHELL_LAUNCH_ATTEMPT=TRUE\n"
        )
        try:
            with open(updater_log, "w", encoding="utf-8") as f_log:
                f_log.write(py_log_entry)
        except Exception:
            pass

        clean_env = os.environ.copy()
        for k in list(clean_env.keys()):
            if k.startswith('_MEIPASS') or k.startswith('_PYI') or k.startswith('PYTHON') or k.startswith('TCL') or k.startswith('TK'):
                clean_env.pop(k, None)
        clean_env['PYINSTALLER_RESET_ENVIRONMENT'] = '1'

        try:
            proc = subprocess.Popen(["powershell.exe", "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", updater_ps1],
                                    creationflags=0x08000000, env=clean_env)
            now_iso = datetime.datetime.utcnow().isoformat() + "Z"
            try:
                with open(updater_log, "a", encoding="utf-8") as f_log:
                    f_log.write(f"[{now_iso}] POWERSHELL_PROCESS_CREATED=TRUE\n")
                    f_log.write(f"[{now_iso}] POWERSHELL_PID={proc.pid}\n")
            except Exception:
                pass
            self.after(50, lambda: (self.destroy(), sys.exit(0)))
        except Exception as p_err:
            now_iso = datetime.datetime.utcnow().isoformat() + "Z"
            safe_err = f"{type(p_err).__name__}: {p_err}"
            try:
                with open(updater_log, "a", encoding="utf-8") as f_log:
                    f_log.write(f"[{now_iso}] POWERSHELL_PROCESS_CREATED=FALSE\n")
                    f_log.write(f"[{now_iso}] ERROR={safe_err}\n")
            except Exception:
                pass
            self.after(0, lambda: self.log(f"Updater launch failed: {safe_err}"))

    def do_get(self, path):
        try:
            ctx = self.get_secure_context()
            req = urllib.request.Request(
                self.server_url + path,
                headers={"Authorization": f"Bearer {self.token}"}
            )
            with urllib.request.urlopen(req, context=ctx, timeout=15) as res:
                return res.getcode(), json.loads(res.read().decode('utf-8'))
        except CertificateMismatchError as e:
            return 0, {"status": "error", "category": "CERTIFICATE_MISMATCH", "message": str(e), "expected": e.expected, "actual": e.actual}
        except (BrainUnreachableError, socket.timeout, TimeoutError, urllib.error.URLError, ConnectionError, OSError) as e:
            return 0, {"status": "unreachable", "category": "BRAIN_UNREACHABLE", "message": str(e)}
        except urllib.error.HTTPError as e:
            try:
                body = json.loads(e.read().decode('utf-8'))
            except Exception:
                body = {"message": str(e)}
            return e.code, body
        except Exception as e:
            return 0, {"status": "error", "category": "UNKNOWN_ERROR", "message": str(e)}

    def do_post(self, path, payload):
        data = json.dumps(payload).encode('utf-8')
        headers = {'Content-Type': 'application/json'}
        if getattr(self, "token", None):
            headers['Authorization'] = f'Bearer {self.token}'
        req = urllib.request.Request(self.server_url + path, data=data, headers=headers)
        try:
            ctx = self.get_secure_context()

            if path == "/developer/portfolio/activate":
                timeout_val = 120
            elif path in ("/chat", "/voice_input") or "creator_request/conversation" in path:
                timeout_val = 60
            else:
                timeout_val = 15

            with urllib.request.urlopen(req, context=ctx, timeout=timeout_val) as res:
                return res.getcode(), json.loads(res.read().decode('utf-8'))
        except CertificateMismatchError as e:
            return 0, {"status": "error", "category": "CERTIFICATE_MISMATCH", "message": str(e), "expected": e.expected, "actual": e.actual}
        except (BrainUnreachableError, socket.timeout, TimeoutError, urllib.error.URLError, ConnectionError, OSError) as e:
            return 0, {"status": "unreachable", "category": "BRAIN_UNREACHABLE", "message": str(e)}
        except urllib.error.HTTPError as e:
            try:
                body = json.loads(e.read().decode('utf-8'))
            except Exception:
                body = {"message": str(e)}
            return e.code, body
        except Exception as e:
            return 0, {"status": "error", "category": "UNKNOWN_ERROR", "message": str(e)}

    def refresh_conversations_gui(self):
        if not self.token:
            return
        code, res = self.do_get("/conversations")
        if code == 200 and "conversations" in res:
            self.conversations_cache = res["conversations"]
            items = []
            selected_idx = 0
            for idx, c in enumerate(self.conversations_cache):
                title = c.get("title") or "Sin título"
                upd = c.get("updated_at", "")[:10]
                display = f"{title} ({upd})" if upd else title
                items.append(display)
                if c.get("id") == self.active_conversation_id:
                    selected_idx = idx
            if hasattr(self, "conv_combo"):
                self.conv_combo["values"] = items
                if items:
                    self.conv_combo.current(selected_idx)
                    self.conv_combo_var.set(items[selected_idx])

    def create_new_conversation_gui(self):
        if not self.token:
            self.log("[Error] Dispositivo no emparejado.")
            return
        code, res = self.do_post("/conversations", {"token": self.token, "title": "Nueva conversación"})
        if code == 200 and res.get("conversation"):
            new_c = res["conversation"]
            self.active_conversation_id = new_c["id"]
            self.config["active_conversation_id"] = self.active_conversation_id
            self.save_config()
            self.chat_area.config(state='normal')
            self.chat_area.delete("1.0", tk.END)
            self.chat_area.config(state='disabled')
            self.log(f"[Sistema] Nueva conversación iniciada.")
            self.refresh_conversations_gui()
        else:
            self.log(f"Error al crear conversación: {res.get('message', 'desconocido')}")

    def _on_conversation_selected_gui(self, event=None):
        if not getattr(self, "conversations_cache", None):
            return
        idx = self.conv_combo.current()
        if 0 <= idx < len(self.conversations_cache):
            target_conv = self.conversations_cache[idx]
            target_id = target_conv["id"]
            if target_id == self.active_conversation_id:
                return
            self.do_post(f"/conversations/{target_id}/active", {"token": self.token})
            code, res = self.do_get(f"/conversations/{target_id}")
            if code == 200 and res.get("conversation"):
                self.active_conversation_id = target_id
                self.config["active_conversation_id"] = target_id
                self.save_config()
                conv_info = res["conversation"]
                msgs = res.get("messages", [])
                self.chat_area.config(state='normal')
                self.chat_area.delete("1.0", tk.END)
                self.chat_area.config(state='disabled')
                self.log(f"=== Conversación Abierta: '{conv_info.get('title')}' ({len(msgs)} mensajes) ===")
                for m in msgs:
                    r_tag = "You" if m.get("role") == "user" else "Optimus"
                    self.log(f"{r_tag}: {m.get('content', '')}")
                self.log("==================================================================")

    def load_active_conversation_history(self):
        if not self.token:
            return
        self.refresh_conversations_gui()
        target_cid = self.active_conversation_id
        if not target_cid and getattr(self, "conversations_cache", None):
            target_cid = self.conversations_cache[0]["id"]

        if target_cid:
            code, res = self.do_get(f"/conversations/{target_cid}")
            if code == 200 and res.get("conversation"):
                self.active_conversation_id = target_cid
                self.config["active_conversation_id"] = target_cid
                self.save_config()
                conv_info = res["conversation"]
                msgs = res.get("messages", [])
                if msgs:
                    self.log(f"=== Historial Restaurado: '{conv_info.get('title')}' ({len(msgs)} mensajes) ===")
                    for m in msgs:
                        r_tag = "You" if m.get("role") == "user" else "Optimus"
                        self.log(f"{r_tag}: {m.get('content', '')}")
                    self.log("==================================================================")
                self.refresh_conversations_gui()

    def pair_device(self, is_retry=False):
        with self._pairing_lock:
            if self._is_pairing:
                return
            self._is_pairing = True
            
        import threading
        threading.Thread(target=self._pair_device_worker, args=(is_retry,), daemon=True).start()

    def _pair_device_worker(self, is_retry=False):
        try:
            if not is_retry:
                self._safe_ui_call(lambda: self.update_status("Conectando..."))
                self._safe_ui_call(lambda: self.log(f"Iniciando conexión con Brain Host para dispositivo {self.device_id}..."))
            
            pair_payload = {"device_id": self.device_id}
            if self.pairing_secret:
                pair_payload["pairing_secret"] = self.pairing_secret
            if self.token:
                pair_payload["token"] = self.token

            code, res = self.do_post("/pair", pair_payload)
            category = res.get("category")
            status = res.get("status")
            
            if category == "BRAIN_UNREACHABLE":
                self._connection_state = "BRAIN_UNREACHABLE"
                delay = int(self._retry_delay)
                self._retry_delay = min(self._retry_delay * 1.5, 15)
                
                self._safe_ui_call(lambda: self.update_status(f"Brain Inaccesible (Reintento en {delay}s)"))
                if not is_retry:
                    self._safe_ui_call(lambda: self.log(f"[Conectividad] Brain inaccesible en {self.server_url}. Reintentando automáticamente en segundo plano..."))
                
                self._safe_ui_call(lambda: self._schedule_retry(delay))
                return
                
            if category == "CERTIFICATE_MISMATCH":
                self._connection_state = "CERTIFICATE_MISMATCH"
                self.authorized_role = "CLIENT"
                self._safe_ui_call(self._apply_authorized_role_ui)
                self._safe_ui_call(lambda: self.update_status("Certificado No Coincide"))
                self._safe_ui_call(lambda: self.log(f"[ALERTA DE SEGURIDAD] {res.get('message')}"))
                self._safe_ui_call(lambda: self.log("[Seguridad] La conexión fue abortada para prevenir interceptación. No se reintentará automáticamente."))
                return

            if status == "approved":
                self._connection_state = "PAIRING_APPROVED"
                self._retry_delay = 3  # reset backoff
                self.token = res.get("token")

                role = str(res.get("role") or "CLIENT").strip().upper()
                if role not in ("CREATOR", "CLIENT"):
                    role = "CLIENT"

                self.authorized_role = role

                self.config["token"] = self.token
                self.config["authorized_role"] = role
                self.save_config()
                self._safe_ui_call(self._apply_authorized_role_ui)
                self._safe_ui_call(lambda: self.update_status("Listo"))
                self._safe_ui_call(lambda: self.log(f"Pairing APPROVED. Authorized role: {self.authorized_role}"))
                self._safe_ui_call(self.load_active_conversation_history)
                self._safe_ui_call(self.check_for_updates)
                self._safe_ui_call(lambda: self.after(500, self.refresh_developer_status))
                return
                
            if status == "pending":
                self._connection_state = "PAIRING_PENDING"
                # Store pairing_secret securely if issued/returned by Brain Host
                sec = res.get("pairing_secret")
                if sec:
                    self.pairing_secret = sec
                    self.config["pairing_secret"] = sec
                    self.save_config()
                self._safe_ui_call(lambda: self.update_status("Pendiente de Aprobación"))
                self._safe_ui_call(lambda: self.log("Pairing PENDING aprobación del propietario en Brain Host..."))
                self._safe_ui_call(lambda: self._schedule_retry(5))
                return
                
            if status == "revoked":
                self._connection_state = "PAIRING_REVOKED"
                self.authorized_role = "CLIENT"
                self.config["authorized_role"] = "CLIENT"
                self.save_config()
                self._safe_ui_call(self._apply_authorized_role_ui)
                self._safe_ui_call(lambda: self.update_status("Pairing Revocado"))
                self._safe_ui_call(lambda: self.log("Pairing REVOKED por el Brain Host."))
                return
                
            # Generic error
            self._connection_state = "UNKNOWN_ERROR"
            self._safe_ui_call(lambda: self.update_status("Error de Conexión"))
            self._safe_ui_call(lambda: self.log(f"Error de pairing: {res.get('message')}"))
            self._safe_ui_call(lambda: self._schedule_retry(10))
            
        finally:
            with self._pairing_lock:
                self._is_pairing = False

    def _schedule_retry(self, delay_sec):
        if hasattr(self, "_retry_timer") and self._retry_timer:
            try:
                self.after_cancel(self._retry_timer)
            except Exception:
                pass
        self._retry_timer = self.after(int(delay_sec * 1000), lambda: self.pair_device(is_retry=True))

    def run_network_self_check(self):
        """
        Runs a layered network diagnostic without inferring subsequent layer failures.
        """
        import threading
        threading.Thread(target=self._run_network_self_check_worker, daemon=True).start()

    def _run_network_self_check_worker(self):
        lines = []
        lines.append("=== NETWORK SELF-CHECK ===")
        
        # 1. Brain configured
        if not self.server_url or not getattr(self, "pinned_fingerprint", None):
            lines.append("Brain configured ........ FAIL (Missing server_url or pinned_fingerprint)")
            lines.append("Network path ............ NOT_TESTED")
            lines.append("TCP 8443 ................ NOT_TESTED")
            lines.append("Certificate obtained .... NOT_TESTED")
            lines.append("Certificate match ....... NOT_TESTED")
            lines.append("Pairing response ........ NOT_TESTED")
            lines.append("Health endpoint ......... NOT_TESTED")
            lines.append("Final classification: CONFIGURATION_MISSING")
            lines.append("==========================")
            self._safe_ui_call(lambda: self.log("\n".join(lines)))
            return

        lines.append(f"Brain configured ........ PASS ({self.server_url})")
        
        try:
            parts = self.server_url.split("://")[1].split(":")
            host = parts[0]
            port = int(parts[1]) if len(parts) > 1 else 8443
        except Exception as e:
            lines.append(f"Brain URL parse ......... FAIL ({e})")
            lines.append("Network path ............ NOT_TESTED")
            lines.append("TCP 8443 ................ NOT_TESTED")
            lines.append("Certificate obtained .... NOT_TESTED")
            lines.append("Certificate match ....... NOT_TESTED")
            lines.append("Pairing response ........ NOT_TESTED")
            lines.append("Health endpoint ......... NOT_TESTED")
            lines.append("Final classification: CONFIGURATION_INVALID")
            lines.append("==========================")
            self._safe_ui_call(lambda: self.log("\n".join(lines)))
            return

        # 2. Network path / TCP Port
        import socket
        tcp_ok = False
        try:
            with socket.create_connection((host, port), timeout=3) as s:
                tcp_ok = True
                lines.append(f"Network path ............ PASS ({host})")
                lines.append(f"TCP {port} ................ PASS")
        except (socket.timeout, TimeoutError) as e:
            lines.append(f"Network path ............ FAIL (Host {host} timed out: {e})")
            lines.append(f"TCP {port} ................ FAIL (Timed out)")
        except ConnectionRefusedError as e:
            lines.append(f"Network path ............ PASS (Host {host} reachable)")
            lines.append(f"TCP {port} ................ FAIL (Connection refused: {e})")
        except Exception as e:
            lines.append(f"Network path ............ FAIL ({e})")
            lines.append(f"TCP {port} ................ FAIL ({e})")

        if not tcp_ok:
            lines.append("Certificate obtained .... NOT_TESTED")
            lines.append("Certificate match ....... NOT_TESTED")
            lines.append("Pairing response ........ NOT_TESTED")
            lines.append("Health endpoint ......... NOT_TESTED")
            lines.append("Final classification: BRAIN_UNREACHABLE")
            lines.append("==========================")
            self._safe_ui_call(lambda: self.log("\n".join(lines)))
            return

        # 3. Certificate obtained
        fp, err = self.fetch_server_certificate_fingerprint(host, port, timeout=5)
        if not fp or len(fp) != 64:
            lines.append(f"Certificate obtained .... FAIL ({err})")
            lines.append("Certificate match ....... NOT_TESTED")
            lines.append("Pairing response ........ NOT_TESTED")
            lines.append("Health endpoint ......... NOT_TESTED")
            lines.append("Final classification: TLS_HANDSHAKE_FAILED")
            lines.append("==========================")
            self._safe_ui_call(lambda: self.log("\n".join(lines)))
            return

        lines.append(f"Certificate obtained .... PASS (SHA256: {fp[:16]}...)")

        # 4. Certificate match
        if fp.lower() != self.pinned_fingerprint.lower():
            lines.append(f"Certificate match ....... FAIL (Expected {self.pinned_fingerprint[:16]}..., got {fp[:16]}...)")
            lines.append("Pairing response ........ NOT_TESTED")
            lines.append("Health endpoint ......... NOT_TESTED")
            lines.append("Final classification: CERTIFICATE_MISMATCH")
            lines.append("==========================")
            self._safe_ui_call(lambda: self.log("\n".join(lines)))
            return

        lines.append("Certificate match ....... PASS")

        # 5. Pairing response
        diag_pair_payload = {"device_id": self.device_id}
        if self.pairing_secret:
            diag_pair_payload["pairing_secret"] = self.pairing_secret
        if self.token:
            diag_pair_payload["token"] = self.token
        code, pair_res = self.do_post("/pair", diag_pair_payload)
        sec = pair_res.get("pairing_secret")
        if sec and not self.pairing_secret:
            self.pairing_secret = sec
            self.config["pairing_secret"] = sec
            self.save_config()
        st = pair_res.get("status", "unknown")
        if st == "approved":
            lines.append("Pairing response ........ APPROVED")
        elif st == "pending":
            lines.append("Pairing response ........ PENDING")
        elif st == "revoked":
            lines.append("Pairing response ........ REVOKED")
            lines.append("Health endpoint ......... NOT_TESTED")
            lines.append("Final classification: PAIRING_REVOKED")
            lines.append("==========================")
            self._safe_ui_call(lambda: self.log("\n".join(lines)))
            return
        else:
            lines.append(f"Pairing response ........ FAIL ({pair_res.get('message', st)})")
            lines.append("Health endpoint ......... NOT_TESTED")
            lines.append("Final classification: UNKNOWN_NETWORK_FAILURE")
            lines.append("==========================")
            self._safe_ui_call(lambda: self.log("\n".join(lines)))
            return

        # 6. Health endpoint
        try:
            req = urllib.request.Request(self.server_url + "/health")
            ctx = ssl._create_unverified_context()
            with urllib.request.urlopen(req, context=ctx, timeout=5) as hres:
                if hres.getcode() == 200:
                    lines.append("Health endpoint ......... PASS")
                else:
                    lines.append(f"Health endpoint ......... FAIL (HTTP {hres.getcode()})")
        except Exception as e:
            lines.append(f"Health endpoint ......... FAIL ({e})")

        final_cls = "READY" if st == "approved" else f"PAIRING_{st.upper()}"
        lines.append(f"Final classification: {final_cls}")
        lines.append("==========================")
        self._safe_ui_call(lambda: self.log("\n".join(lines)))

    def _on_enter(self, event=None):
        self.send_text()
        return "break"

    def send_text(self):
        text = self.input_var.get().strip()
        if not text:
            return
        self.input_var.set("")

        if text.startswith("/dev ") or text.startswith("/mision ") or text.startswith("/mission "):
            parts = text.split(" ", 1)
            obj = parts[1].strip()
            if obj:
                self.create_developer_mission(obj)
                return

        if text.lower() in ("/diag", "/diagnostico", "/diagnostics"):
            self.voice_diagnostics = not getattr(self, "voice_diagnostics", False)
            self.config["voice_diagnostics"] = self.voice_diagnostics
            self.save_config()
            state_str = "ACTIVADO" if self.voice_diagnostics else "DESACTIVADO"
            self.log(f"[Sistema] Modo diagnóstico de voz: {state_str}")
            return

        cmd_lower = text.lower().strip()
        if cmd_lower in ("/check", "/selfcheck", "/netcheck", "/red", "/test_net", "/diagnostico_red"):
            self.log("[Diagnóstico] Ejecutando NETWORK SELF-CHECK...")
            self.run_network_self_check()
            return
        if cmd_lower in ("/pause", "/pausa", "pausa"):
            self.toggle_pause()
            return
        if cmd_lower in ("/resume", "/reanudar", "reanudar"):
            if LocalActionExecutor.is_paused():
                self.toggle_pause()
            else:
                self.log("[Seguridad Local] El ejecutor no estaba en pausa.")
            return
        if cmd_lower in ("/cancel", "/cancelar", "cancelar"):
            self.trigger_cancel()
            return
        if cmd_lower in ("/kill", "/parada", "/stop", "parada de emergencia", "kill switch"):
            if not LocalActionExecutor.is_kill_switch_active():
                self.toggle_kill_switch()
            else:
                self.log("[KILL SWITCH] Parada de emergencia ya se encuentra activa.")
            return
        if cmd_lower in ("/reset", "/restablecer", "restablecer"):
            if LocalActionExecutor.is_kill_switch_active() or LocalActionExecutor.is_paused():
                LocalActionExecutor.reset_control()
                self._apply_kill_ui_state(False)
                self.log("[Seguridad Local] Sistema restablecido. Acciones locales autorizadas nuevamente.")
            else:
                self.log("[Seguridad Local] El sistema ya está en estado operativo normal.")
            return

        # Continuity slash commands
        if cmd_lower in ("/history", "/conversaciones", "/conversations"):
            code, res = self.do_get("/conversations")
            if code == 200 and "conversations" in res:
                convs = res["conversations"]
                self.log(f"=== Conversaciones Registradas ({len(convs)}) ===")
                for c in convs:
                    active_mark = " (ACTIVA)" if c.get("id") == self.active_conversation_id else ""
                    self.log(f"- [{c.get('id')}]{active_mark} \"{c.get('title')}\" (Actualizado: {c.get('updated_at', '')[:19]})")
                self.log("Usa /open <id> para abrir una conversación anterior.")
                self.log("Usa /new <titulo> para iniciar una nueva conversación.")
            else:
                self.log(f"Error al listar conversaciones: {res.get('message', 'desconocido')}")
            return

        if cmd_lower == "/new" or cmd_lower.startswith("/new ") or cmd_lower == "/nueva" or cmd_lower.startswith("/nueva "):
            parts = text.split(" ", 1)
            title = parts[1].strip() if len(parts) > 1 and parts[1].strip() else "Nueva Conversación"
            code, res = self.do_post("/conversations", {"token": self.token, "title": title})
            if code == 200 and res.get("conversation"):
                new_c = res["conversation"]
                self.active_conversation_id = new_c["id"]
                self.config["active_conversation_id"] = self.active_conversation_id
                self.save_config()
                self.log(f"[Sistema] Nueva conversación iniciada: '{new_c.get('title')}' (ID: {self.active_conversation_id})")
            else:
                self.log(f"Error al crear conversación: {res.get('message', 'desconocido')}")
            return

        if cmd_lower.startswith("/open ") or cmd_lower.startswith("/abrir "):
            parts = text.split(" ", 1)
            target_id = parts[1].strip() if len(parts) > 1 else ""
            if not target_id:
                self.log("Uso: /open <conversation_id>")
                return
            self.do_post(f"/conversations/{target_id}/active", {"token": self.token})
            code, res = self.do_get(f"/conversations/{target_id}")
            if code == 200 and res.get("conversation"):
                self.active_conversation_id = target_id
                self.config["active_conversation_id"] = self.active_conversation_id
                self.save_config()
                conv_info = res["conversation"]
                msgs = res.get("messages", [])
                self.chat_area.config(state='normal')
                self.chat_area.delete("1.0", tk.END)
                self.chat_area.config(state='disabled')
                self.log(f"=== Conversación Abierta: '{conv_info.get('title')}' ({len(msgs)} mensajes) ===")
                for m in msgs:
                    r_tag = "You" if m.get("role") == "user" else "Optimus"
                    self.log(f"{r_tag}: {m.get('content', '')}")
                self.log("==================================================================")
            else:
                self.log(f"Conversación '{target_id}' no encontrada o error: {res.get('message')}")
            return

        if cmd_lower in ("/reports", "/informes"):
            code, res = self.do_get("/reports")
            if code == 200 and "reports" in res:
                reps = res["reports"]
                self.log(f"=== Informes Disponibles ({len(reps)}) ===")
                for r in reps:
                    self.log(f"- [{r.get('id')}] Proyecto: {r.get('project_name', r.get('project_id'))} | Estado: {r.get('final_status')} | Fecha: {r.get('created_at', '')[:19]}")
                self.log("Usa /report <id> para ver los detalles completos y rutas del informe.")
            else:
                self.log(f"Error al listar informes: {res.get('message', 'desconocido')}")
            return

        if cmd_lower.startswith("/report ") or cmd_lower.startswith("/informe "):
            parts = text.split(" ", 1)
            target_id = parts[1].strip() if len(parts) > 1 else ""
            if not target_id:
                self.log("Uso: /report <report_id>")
                return
            code, res = self.do_get(f"/reports/{target_id}")
            if code == 200 and res.get("report"):
                rep = res["report"]
                self.log(f"=== Detalle de Informe: {rep.get('title', target_id)} ===")
                self.log(f"Report ID: {rep.get('id')}")
                self.log(f"Estado Final: {rep.get('final_status')}")
                self.log(f"Ubicación del Producto: {rep.get('product_location')}")
                self.log(f"Informe Markdown: {rep.get('markdown_path')}")
                self.log(f"Informe JSON: {rep.get('json_path')}")
                self.log(f"Resumen: {rep.get('summary')}")
                self.log("==================================================")
            else:
                self.log(f"Informe '{target_id}' no encontrado o error: {res.get('message')}")
            return

        if not self.token:
            self.log("Error: Dispositivo no emparejado.")
            return

        if getattr(self, "_sending", False):
            self.log("[Sistema] Esperando respuesta anterior...")
            return

        self._sending = True
        self.update_status("Pensando...")
        if hasattr(self, "send_btn"):
            try:
                self.send_btn.config(state="disabled")
            except Exception:
                pass

        self.log(f"You: {text}")
        chat_payload = {"token": self.token, "input_mode": "TEXT", "text": text}
        if getattr(self, "active_conversation_id", None):
            chat_payload["conversation_id"] = self.active_conversation_id
            if getattr(self, "conversations_cache", None):
                for c in self.conversations_cache:
                    if c.get("id") == self.active_conversation_id and c.get("title") in ("Nueva conversación", "Nueva Conversación"):
                        new_title = text.strip()[:32]
                        self.do_post(f"/conversations/{self.active_conversation_id}/title", {"token": self.token, "title": new_title})
                        break

        import threading
        threading.Thread(target=self._send_chat_thread, args=(chat_payload,), daemon=True).start()

    def _send_chat_thread(self, chat_payload):
        try:
            code, res = self.do_post("/chat", chat_payload)
            def _on_finish():
                self._sending = False
                if hasattr(self, "send_btn"):
                    try:
                        self.send_btn.config(state="normal")
                    except Exception:
                        pass
                self.update_status("Listo")

                if code == 200:
                    if res.get("conversation_id"):
                        self.active_conversation_id = res["conversation_id"]
                        self.config["active_conversation_id"] = self.active_conversation_id
                        self.save_config()

                    self.refresh_conversations_gui()

                    self.log(f"Optimus: {res.get('response')}")
                    if res.get("action_request"):
                        act_req = res["action_request"]
                        self.log(f"[Acción recibida] Ejecutando {act_req.get('action')} en tu equipo...")
                        import threading
                        threading.Thread(target=self._handle_action_execution, args=(act_req,), daemon=True).start()
                else:
                    err_msg = res.get("message") if isinstance(res, dict) else str(res)
                    self.log(f"Error: {err_msg}")

            self._safe_ui_call(_on_finish)
        except Exception as e:
            def _on_err():
                self._sending = False
                if hasattr(self, "send_btn"):
                    try:
                        self.send_btn.config(state="normal")
                    except Exception:
                        pass
                self.update_status("Listo")
                self.log(f"Error de conexión: {e}")
            self._safe_ui_call(_on_err)

    def _safe_ui_call(self, callback):
        if hasattr(self, "_ui_queue"):
            self._ui_queue.put(callback)
            try:
                self.after(0, self._process_ui_queue)
            except Exception:
                pass
        else:
            try:
                self.after(0, callback)
            except Exception:
                try:
                    callback()
                except Exception:
                    pass

    def _process_ui_queue(self):
        while hasattr(self, "_ui_queue") and not self._ui_queue.empty():
            try:
                fn = self._ui_queue.get_nowait()
                fn()
            except Exception:
                pass
        try:
            self.after(50, self._process_ui_queue)
        except Exception:
            pass

    def refresh_developer_status(self):
        if not self.token:
            return
        code, res = self.do_get("/developer/summary")
        if code == 200 and "summary" in res:
            summary = res["summary"]
            pending = summary.get("reviews_pending", 0) + summary.get("candidates_ready_for_review", 0)
            unread = summary.get("notifications_unread", 0)
            count = max(pending, unread)
            if hasattr(self, "notif_btn_var"):
                self.notif_btn_var.set(f"🔔 Revisiones ({count})")
        # Periodic refresh every 15s
        try:
            self.after(15000, self.refresh_developer_status)
        except Exception:
            pass

    def create_developer_mission(self, objective: str, project_id: str = "dw3_physical_gate"):
        if not self.token:
            self.log("[Error] Dispositivo no emparejado.")
            return
        self.log(f"🚀 [Developer Workspace] Creando misión en {project_id}...")
        self.log(f"   Objetivo: {objective}")
        self.update_status("Ejecutando Misión Dev...")

        def _thread():
            payload = {
                "token": self.token,
                "project_id": project_id,
                "objective": objective,
                "auto_run": True,
                "max_steps": 10,
            }
            code, res = self.do_post("/developer/mission", payload)
            if code == 200:
                mid = res.get("mission_id", "")
                m_st = res.get("mission_status", "")
                ws = res.get("workspace_root", "")
                exec_res = res.get("execution_result", {})
                self.after(0, lambda: self.log(f"✅ [Misión Dev] ID: {mid} | Estado: {m_st}"))
                self.after(0, lambda: self.log(f"   Workspace: {ws}"))
                if exec_res:
                    loop_st = exec_res.get("loop_status") or exec_res.get("mode", "")
                    steps = exec_res.get("steps_executed", 0)
                    self.after(0, lambda: self.log(f"   Pasos ejecutados: {steps} | Resultado loop: {loop_st}"))
            else:
                err = res.get("message", "Error desconocido")
                self.after(0, lambda: self.log(f"❌ [Error Misión Dev] {err}"))
            self.after(0, lambda: self.update_status("Listo"))
            self.after(0, self.refresh_developer_status)

        import threading
        threading.Thread(target=_thread, daemon=True).start()

    def open_developer_mission_dialog(self):
        dlg = tk.Toplevel(self)
        configure_normal_work_window(dlg, title="Crear Misión de Desarrollo", geometry="520x340", minsize=(480, 300))

        tk.Label(dlg, text="Proyecto / Workspace ID:", font=("Arial", 9, "bold")).pack(anchor=tk.W, padx=10, pady=(10, 2))
        proj_var = tk.StringVar(value="dw3_physical_gate")
        proj_entry = tk.Entry(dlg, textvariable=proj_var, width=40)
        proj_entry.pack(fill=tk.X, padx=10, pady=(0, 10))

        tk.Label(dlg, text="Objetivo de la Misión:", font=("Arial", 9, "bold")).pack(anchor=tk.W, padx=10, pady=(0, 2))
        obj_text = scrolledtext.ScrolledText(dlg, height=6)
        obj_text.pack(fill=tk.BOTH, expand=True, padx=10, pady=(0, 10))
        obj_text.insert(tk.END, "Lee project_notes.txt y crea summary.txt con un resumen breve del proyecto. Después verifica que el archivo exista y que el contenido corresponda a las notas.")

        def _on_submit():
            proj = proj_var.get().strip() or "dw3_physical_gate"
            obj = obj_text.get("1.0", tk.END).strip()
            if not obj:
                messagebox.showwarning("Campo requerido", "Por favor ingresa un objetivo para la misión.")
                return
            dlg.destroy()
            self.create_developer_mission(objective=obj, project_id=proj)

        btn_frame = tk.Frame(dlg)
        btn_frame.pack(fill=tk.X, padx=10, pady=(0, 10))
        tk.Button(btn_frame, text="🚀 Iniciar Misión", command=_on_submit, bg="#cfe2ff", font=("Arial", 9, "bold")).pack(side=tk.RIGHT, padx=5)
        tk.Button(btn_frame, text="Cancelar", command=dlg.destroy).pack(side=tk.RIGHT)

    def open_reviews_dialog(self):
        code, res = self.do_get("/developer/reviews")
        reviews = res.get("reviews", []) if code == 200 else []

        dlg = tk.Toplevel(self)
        configure_normal_work_window(dlg, title="Centro de Revisión del Creador (Approval Gate)", geometry="650x520", minsize=(550, 400))

        tk.Label(dlg, text="Revisiones de Capabilities Pendientes", font=("Arial", 11, "bold")).pack(anchor=tk.W, padx=10, pady=(10, 5))

        if not reviews:
            tk.Label(dlg, text="No hay revisiones registradas actualmente.", fg="gray").pack(padx=10, pady=20)
            tk.Button(dlg, text="Cerrar", command=dlg.destroy).pack(pady=10)
            return

        # Frame for details
        details_frame = tk.Frame(dlg, relief=tk.RIDGE, bd=1)
        details_frame.pack(fill=tk.BOTH, expand=True, padx=10, pady=5)

        rev = reviews[0]  # Show most recent pending or first
        for r in reviews:
            if r.get("creator_status") == "PENDING":
                rev = r
                break

        rev_id = rev.get("review_id", "")
        cap_id = rev.get("capability_id", "")
        cap_ver = rev.get("candidate_version", "")
        status = rev.get("creator_status", "")
        risk = rev.get("risk_summary", "LOW")
        what = rev.get("what_it_does", "")
        why = rev.get("why_it_was_created", "")
        tests_p = rev.get("tests_pass", 0)
        tests_r = rev.get("tests_run", 0)
        perms = ", ".join(rev.get("permissions_required", [])) or "Ninguno"
        created_files = ", ".join(rev.get("files_created", [])) or "Ninguno"

        info_lines = [
            f"Review ID: {rev_id}",
            f"Capability: {cap_id} (Versión: {cap_ver}) | Estado: {status}",
            f"Riesgo: {risk} | Pruebas Sandbox: {tests_p}/{tests_r} PASS",
            f"Permisos solicitados: {perms}",
            f"Archivos en sandbox: {created_files}",
            f"Qué hace: {what}",
            f"Motivo / Gap detectado: {why}",
        ]
        info_box = scrolledtext.ScrolledText(details_frame, height=9, font=("Consolas", 9))
        info_box.pack(fill=tk.BOTH, expand=True, padx=5, pady=5)
        info_box.insert(tk.END, "\n".join(info_lines))
        info_box.config(state="disabled")

        tk.Label(dlg, text="Feedback del Creador (obligatorio para Request Changes / opcional para Approve):", font=("Arial", 9, "bold")).pack(anchor=tk.W, padx=10, pady=(5, 2))
        feedback_entry = tk.Entry(dlg, width=70)
        feedback_entry.pack(fill=tk.X, padx=10, pady=(0, 10))

        def _send_decision(decision: str):
            fb = feedback_entry.get().strip()
            if decision == "REQUEST_CHANGES" and not fb:
                messagebox.showwarning("Feedback requerido", "Por favor escribe las instrucciones de cambio en el campo de Feedback.")
                return
            dlg.destroy()
            self.log(f"📝 Enviando decisión '{decision}' para {cap_id}...")

            def _thread():
                payload = {
                    "token": self.token,
                    "review_id": rev_id,
                    "decision": decision,
                    "feedback": fb or f"Decisión: {decision}",
                    "approver": "creator",
                    "auto_promote": True,
                }
                c, r = self.do_post("/developer/review/decision", payload)
                if c == 200:
                    dec = r.get("decision")
                    self.after(0, lambda: self.log(f"✅ [Revisión] Decisión {dec} registrada exitosamente."))
                    if r.get("promotion"):
                        promo_st = r["promotion"].get("status")
                        self.after(0, lambda: self.log(f"   Promoción: {promo_st} (Capability disponible)"))
                    if r.get("mission_resumed"):
                        self.after(0, lambda: self.log("   Misión original REANUDADA desde su paso pendiente."))
                else:
                    err = r.get("message", "Error")
                    self.after(0, lambda: self.log(f"❌ [Error Revisión] {err}"))
                self.after(0, self.refresh_developer_status)

            import threading
            threading.Thread(target=_thread, daemon=True).start()

        btn_row = tk.Frame(dlg)
        btn_row.pack(fill=tk.X, padx=10, pady=(0, 10))

        tk.Button(btn_row, text="✅ Aprobar (APPROVE)", command=lambda: _send_decision("APPROVE"), bg="#d1e7dd", fg="#0f5132", font=("Arial", 9, "bold")).pack(side=tk.LEFT, padx=5)
        tk.Button(btn_row, text="🔄 Solicitar Cambios (REQUEST_CHANGES)", command=lambda: _send_decision("REQUEST_CHANGES"), bg="#fff3cd", fg="#664d03", font=("Arial", 9, "bold")).pack(side=tk.LEFT, padx=5)
        tk.Button(btn_row, text="❌ Rechazar (REJECT)", command=lambda: _send_decision("REJECT"), bg="#f8d7da", fg="#842029", font=("Arial", 9, "bold")).pack(side=tk.LEFT, padx=5)
        tk.Button(btn_row, text="Cerrar", command=dlg.destroy).pack(side=tk.RIGHT, padx=5)

    def send_voice(self):
        if not self.token:
            self.log("Error: Dispositivo no emparejado.")
            return
        if getattr(self, "_recording", False):
            return

        self._recording = True
        if hasattr(self, "voice_btn"):
            try:
                self.voice_btn.config(text="🎙 Escuchando...", state="disabled")
            except Exception:
                pass

        import threading
        threading.Thread(target=self._record_and_send_voice_thread, daemon=True).start()

    def capture_microphone_wav_bytes(self, status_callback=None) -> Optional[bytes]:
        import pyaudio
        import wave
        import tempfile
        import os
        import time

        pa = None
        stream = None
        tmp_wav = None

        def _update_st(txt):
            if status_callback:
                try:
                    self.after(0, lambda: status_callback(txt))
                except Exception:
                    pass

        try:
            try:
                pa = pyaudio.PyAudio()
            except Exception as e:
                self.after(0, lambda: self.log("Error: No pude acceder al micrófono."))
                self.diag_log(f"[Error Técnico PyAudio] {e}")
                _update_st("Error PyAudio")
                return None

            devices = enumerate_input_devices(pa)
            if not devices:
                self.after(0, lambda: self.log("Error: No pude acceder al micrófono."))
                self.diag_log("[Error Técnico] No se detectó ningún dispositivo de entrada.")
                _update_st("Sin Micrófono")
                return None

            sel_dev, reason = select_input_device(devices, self.config)
            if not sel_dev:
                self.after(0, lambda: self.log("Error: No pude acceder al micrófono."))
                self.diag_log("[Error Técnico] Falló selección de dispositivo de entrada.")
                _update_st("Sin Micrófono")
                return None

            input_device_index = sel_dev["index"]
            dev_info = sel_dev["raw_info"]
            dev_name = sel_dev["name"]
            api_name = sel_dev["host_api_name"]

            self.diag_log(f"[Microfono] Seleccionado: [{input_device_index}] {dev_name} ({api_name})")

            native_rate = int(dev_info.get("defaultSampleRate", 16000))
            max_ch = int(dev_info.get("maxInputChannels", 1))

            target_rate = 16000
            target_channels = 1
            chunk = 1024

            try:
                stream = pa.open(
                    format=pyaudio.paInt16,
                    channels=target_channels,
                    rate=target_rate,
                    input=True,
                    input_device_index=input_device_index,
                    frames_per_buffer=chunk
                )
            except Exception:
                try:
                    target_rate = native_rate
                    target_channels = min(2, max(1, max_ch))
                    stream = pa.open(
                        format=pyaudio.paInt16,
                        channels=target_channels,
                        rate=target_rate,
                        input=True,
                        input_device_index=input_device_index,
                        frames_per_buffer=chunk
                    )
                except Exception as open_err:
                    self.after(0, lambda: self.log("Error: No pude acceder al micrófono."))
                    self.diag_log(f"[Error Técnico Stream] {open_err}")
                    _update_st("Error Micrófono")
                    return None

            _update_st("🎙 Escuchando (4s)...")
            self.after(0, lambda: self.log("🎙 Escuchando (4s)... Habla ahora."))

            frames = []
            num_chunks = max(1, int((target_rate / chunk) * 4.0))
            for _ in range(num_chunks):
                data = stream.read(chunk, exception_on_overflow=False)
                frames.append(data)

            _update_st("Procesando voz...")

            try:
                stream.stop_stream()
                stream.close()
                stream = None
            except Exception:
                pass

            if not frames:
                self.after(0, lambda: self.log("Error: No pude escuchar tu voz."))
                _update_st("Sin audio")
                return None

            metrics = compute_pcm_metrics(frames, target_rate, target_channels)
            peak_val = metrics["peak"]
            peak_pct = metrics["peak_pct"]
            rms_val = metrics["rms"]
            dur_sec = metrics["duration_sec"]
            tot_bytes = metrics["total_bytes"]
            self.diag_log(f"[Audio Diagnóstico] Bytes: {tot_bytes}, Duración: {dur_sec:.2f}s, Peak: {peak_val} ({peak_pct:.1f}%), RMS: {rms_val:.1f}")

            with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as f:
                tmp_wav = f.name

            try:
                with wave.open(tmp_wav, "wb") as wf:
                    wf.setnchannels(target_channels)
                    wf.setsampwidth(pa.get_sample_size(pyaudio.paInt16))
                    wf.setframerate(target_rate)
                    wf.writeframes(b"".join(frames))

                with open(tmp_wav, "rb") as wf:
                    return wf.read()
            finally:
                if tmp_wav and os.path.exists(tmp_wav):
                    try:
                        os.remove(tmp_wav)
                    except Exception:
                        pass
        finally:
            if stream is not None:
                try:
                    stream.stop_stream()
                    stream.close()
                except Exception:
                    pass
            if pa is not None:
                try:
                    pa.terminate()
                except Exception:
                    pass

    def _record_and_send_voice_thread(self):
        import base64
        try:
            wav_bytes = self.capture_microphone_wav_bytes(
                status_callback=lambda st: self.update_status(st)
            )
            if not wav_bytes:
                self.after(0, lambda: self.update_status("Listo"))
                return
            audio_b64 = base64.b64encode(wav_bytes).decode("ascii")
            self.diag_log("Enviando audio al Brain Host...")

            payload = {
                "token": self.token,
                "input_mode": "VOICE",
                "audio_b64": audio_b64,
                "format": "wav",
            }
            if getattr(self, "active_conversation_id", None):
                payload["conversation_id"] = self.active_conversation_id

            code, res = self.do_post("/voice_input", payload)

            if code == 200:
                if res.get("conversation_id"):
                    self.active_conversation_id = res["conversation_id"]
                    self.config["active_conversation_id"] = self.active_conversation_id
                    self.save_config()

                transcription = (res.get("transcription") or "").strip()
                response_text = res.get("response", "")
                stt_status = res.get("stt_status")

                if transcription:
                    stt_status = "SUCCESS"
                    self.after(0, lambda t=transcription: self.log(f"You (Voz): {t}"))
                    self.after(0, lambda r=response_text: self.log(f"Optimus: {r}"))
                    self.diag_log(f"[STT Status] {stt_status}")
                    if res.get("action_request"):
                        act_req = res["action_request"]
                        self.after(0, lambda: self.log(f"[Acción recibida] Ejecutando {act_req.get('action')} en tu equipo..."))
                        import threading
                        threading.Thread(target=self._handle_action_execution, args=(act_req,), daemon=True).start()
                else:
                    stt_status = stt_status or "UNKNOWN_AUDIO"
                    self.after(0, lambda: self.log("You (Voz): [Audio no reconocido]"))
                    self.after(0, lambda r=response_text: self.log(f"Optimus: {r}"))
                    self.diag_log(f"[STT Status] {stt_status} ({res.get('message', '')})")

                self.after(0, lambda: self.update_status("Listo"))
            else:
                err_msg = res.get("response") or res.get("message") or f"HTTP {code}"
                stt_status = res.get("stt_status", f"HTTP_{code}")
                self.after(0, lambda: self.log("Error: No pude conectar con el servicio de voz."))
                self.diag_log(f"[Error Técnico] {err_msg} (STT Status: {stt_status})")
                self.after(0, lambda: self.update_status("Listo"))
        except Exception as e:
            self.after(0, lambda: self.log("Error: No pude capturar el audio."))
            self.diag_log(f"[Error Técnico Excepción] {e}")
            self.after(0, lambda: self.update_status("Listo"))
        finally:
            if stream is not None:
                try:
                    stream.stop_stream()
                    stream.close()
                except Exception:
                    pass
            if pa is not None:
                try:
                    pa.terminate()
                except Exception:
                    pass
            self._recording = False
            if hasattr(self, "voice_btn"):
                self.after(0, lambda: self.voice_btn.config(text="Hablar", state="normal"))

    def _handle_action_execution(self, action_request):
        try:
            self.after(0, lambda: self.update_status("Ejecutando acción..."))
            res = LocalActionExecutor.execute(action_request)
            ver = res.get("verification", {})
            act = res.get("action") or action_request.get("action", "")

            if res.get("status") == "SUCCESS":
                if act in ("OPEN_APPLICATION", "OPEN_AND_TYPE_TEXT"):
                    ch_typed = ver.get("chars_typed", 0)
                    obs_len = ver.get("observed_length", 0)
                    c_match = ver.get("content_match", False)
                    self.after(0, lambda: self.log("[Acción local] Bloc de notas abierto en tu laptop."))
                    if ch_typed > 0:
                        self.after(0, lambda: self.log(f"[Acción local] Texto escrito: {ch_typed} caracteres."))
                        match_str = "Coincidencia verificada" if c_match else "Texto transmitido"
                        self.after(0, lambda: self.log(f"[Verificación] {match_str} (Longitud leída: {obs_len})."))
                    self.after(0, lambda: self.log("[Acción local] Foco restaurado a Digital Optimus."))
                elif act == "MOUSE_MOVE":
                    actual_pos = ver.get("actual_position", {})
                    delta = ver.get("delta", {})
                    self.after(0, lambda: self.log(f"[Acción local] Cursor movido a ({actual_pos.get('x')}, {actual_pos.get('y')})."))
                    self.after(0, lambda: self.log(f"[Verificación] Posición confirmada (dx={delta.get('dx')}, dy={delta.get('dy')})."))
                elif act in ("SAFE_CLICK_TEST", "MOUSE_CLICK"):
                    p_cnt = ver.get("post_counter")
                    if p_cnt is not None:
                        sp = ver.get("safe_point", ver.get("cursor_at_click", {}))
                        self.after(0, lambda: self.log(f"[Acción local] Clic izquierdo ejecutado en target seguro ({sp.get('x')}, {sp.get('y')})."))
                        self.after(0, lambda: self.log(f"[Verificación] Clic verificado (Contador: {p_cnt})."))
                    else:
                        click_pos = ver.get("click_position", {})
                        self.after(0, lambda: self.log(f"[Acción local] Clic izquierdo ejecutado en ({click_pos.get('x')}, {click_pos.get('y')})."))
                        self.after(0, lambda: self.log("[Verificación] Clic verificado."))
                elif act == "KEY_PRESS":
                    k = ver.get("key_pressed")
                    self.after(0, lambda: self.log(f"[Acción local] Pulsación de tecla ejecutada: {k}."))
                    self.after(0, lambda: self.log("[Verificación] Pulsación verificada."))
                elif act == "OBSERVE_SCREEN":
                    scr = ver.get("screen", {})
                    fg = ver.get("foreground_window", "")
                    cur = ver.get("cursor", {})
                    w, h = scr.get("width", 0), scr.get("height", 0)
                    cx, cy = cur.get("x", 0), cur.get("y", 0)
                    self.after(0, lambda: self.log(f"[Observación de pantalla] Resolución: {w}x{h}. Cursor: ({cx}, {cy}). Ventana: '{fg}'."))
                    self.after(0, lambda: self.log("[Verificación] Geometría de pantalla y posición de cursor confirmadas."))
                elif act == "OBSERVE_ACTIVE_WINDOW":
                    scr = ver.get("screen", {})
                    fg = ver.get("foreground_window", "")
                    self.after(0, lambda: self.log(f"[Observación local] Ventana activa: '{fg}'. Pantalla: {scr.get('width')}x{scr.get('height')}."))
                    self.after(0, lambda: self.log("[Verificación] Observación de entorno registrada."))
                elif act in ("SAFE_WINDOW_TEST", "WINDOW_CONTROL"):
                    target_t = ver.get("target_title", "Ventana")
                    mid_min = ver.get("mid_minimized", False)
                    post_min = ver.get("post_minimized", False)
                    self.after(0, lambda: self.log(f"[Acción local] Ventana '{target_t}' minimizada y restaurada exitosamente."))
                    self.after(0, lambda: self.log(f"[Verificación] Ciclo de ventana confirmado (Minimizada: {mid_min} -> Restaurada: {not post_min})."))
                elif act == "SAFETY_PAUSE":
                    self.after(0, lambda: self._apply_pause_ui_state(True))
                    self.after(0, lambda: self.log("[Seguridad] Acciones locales PAUSADAS."))
                elif act == "SAFETY_RESUME":
                    self.after(0, lambda: self._apply_pause_ui_state(False))
                    self.after(0, lambda: self.log("[Seguridad] Acciones locales REANUDADAS."))
                elif act == "SAFETY_CANCEL":
                    self.after(0, lambda: self.log("[Seguridad] Señal de cancelación procesada."))
                elif act == "SAFETY_KILL_SWITCH":
                    self.after(0, lambda: self._apply_kill_ui_state(True))
                    self.after(0, lambda: self.log("[KILL SWITCH] 🛑 Parada de emergencia activada. Acciones locales bloqueadas."))
                elif act == "SAFETY_RESET":
                    self.after(0, lambda: self._apply_kill_ui_state(False))
                    self.after(0, lambda: self.log("[Seguridad] Sistema restablecido. Acciones locales autorizadas."))
                else:
                    self.after(0, lambda: self.log(f"[Acción local] {act} ejecutada exitosamente."))
            else:
                st = res.get("status")
                if st == "PAUSED":
                    self.after(0, lambda: self.log("[Seguridad] Acción bloqueada: el ejecutor local está en PAUSA."))
                elif st == "CANCELLED":
                    self.after(0, lambda: self.log("[Seguridad] Acción bloqueada: la ejecución ha sido CANCELADA."))
                elif st == "KILL_SWITCH_ACTIVE":
                    self.after(0, lambda: self.log("[KILL SWITCH] 🛑 Acción bloqueada: PARADA DE EMERGENCIA ACTIVA."))
                else:
                    err = res.get("error") or res.get("status") or (res.get("verification") and res.get("verification").get("reason")) or "Error desconocido"
                    self.after(0, lambda: self.log(f"[Error Acción] {err}"))

            # Report evidence back to Brain Host
            if self.token:
                payload = {
                    "token": self.token,
                    "device_id": self.device_id,
                    "request_id": action_request.get("request_id"),
                    "action": act,
                    "status": res.get("status"),
                    "verification": ver,
                    "pre_observation": res.get("pre_observation"),
                    "post_observation": res.get("post_observation"),
                }
                self.do_post("/action_result", payload)

            def _restore_status():
                if LocalActionExecutor.is_kill_switch_active():
                    self.update_status("KILL SWITCH ACTIVO (Acciones Bloqueadas)")
                elif LocalActionExecutor.is_paused():
                    self.update_status("PAUSADO")
                else:
                    self.update_status("Listo")

            self.after(0, _restore_status)
        except Exception as e:
            self.after(0, lambda: self.log(f"[Error Ejecución Local] {e}"))
            self.after(0, lambda: self.update_status("Listo"))

if __name__ == "__main__":
    maybe_bootstrap_canonical_install()
    ensure_windows_app_shortcuts()
    app = DigitalOptimusClient()
    if len(sys.argv) > 1 and sys.argv[1] == "--test":
        app.update()
        app.send_text()
        app.destroy()
    else:
        app.mainloop()
