"""SalmAlm tool handlers — execution logic for all 43 tools."""
import subprocess, sys, os, re, time, json, traceback, uuid, secrets
import urllib.request, base64, mimetypes, difflib, threading
from datetime import datetime
from pathlib import Path
from html.parser import HTMLParser
try:
    import resource as _resource_mod
except ImportError:
    _resource_mod = None  # type: ignore[assignment]

from .constants import (EXEC_ALLOWLIST, EXEC_BLOCKLIST, EXEC_BLOCKLIST_PATTERNS, EXEC_ELEVATED,
                        PROTECTED_FILES, WORKSPACE_DIR, VERSION, KST, MEMORY_FILE, MEMORY_DIR, AUDIT_DB)
from .crypto import vault, log
from .core import (audit_log, get_usage_report, _tfidf, SubAgent, SkillLoader,
                   _sessions, get_session, _tg_bot, memory_manager)
from .llm import _http_post, _http_get

# clipboard lock
_clipboard_lock = threading.Lock()
telegram_bot = None

def _is_safe_command(cmd: str) :
    """Check if command is safe to execute (allowlist + blocklist double defense)."""
    if not cmd.strip():
        return False, 'Empty command'

    # Blocklist patterns first (catches dangerous combos)
    for pattern in EXEC_BLOCKLIST_PATTERNS:
        if re.search(pattern, cmd):
            return False, f'Blocked pattern: {pattern}'

    # Split on pipe/chain operators and check EVERY stage
    # This prevents: curl ... | sh  (curl is allowed, sh is not)
    stages = re.split(r'\s*(?:\|\||&&|;|\|)\s*', cmd)
    for stage in stages:
        words = stage.strip().split()
        if not words:
            continue
        first_word = words[0].split('/')[-1]  # strip path prefix
        if first_word in EXEC_BLOCKLIST:
            return False, f'Blocked command in pipeline: {first_word}'
        if first_word in EXEC_ELEVATED:
            log.warning(f"[WARN] Elevated exec: {first_word} (can run arbitrary code)")
        elif first_word not in EXEC_ALLOWLIST:
            return False, f'Command not in allowlist: {first_word}'

    # Check for subshell/backtick/process substitution bypasses
    if re.search(r'`.*`|\$\(.*\)|<\(|>\(', cmd):
        # Verify the inner command too
        inner = re.findall(r'`([^`]+)`|\$\(([^)]+)\)', cmd)
        for groups in inner:
            inner_cmd = groups[0] or groups[1]
            inner_first = inner_cmd.strip().split()[0].split('/')[-1] if inner_cmd.strip() else ''
            if inner_first and inner_first not in EXEC_ALLOWLIST:
                return False, f'Blocked subshell command: {inner_first}'

    return True, ''


def _resolve_path(path: str, writing: bool = False) -> Path:
    """Resolve path, preventing traversal outside allowed directories.

    Read: workspace + home directory
    Write: workspace only (stricter)
    """
    p = Path(path)
    if not p.is_absolute():
        p = WORKSPACE_DIR / p
    p = p.resolve()

    if writing:
        # Write operations: workspace only
        try:
            p.relative_to(WORKSPACE_DIR.resolve())
        except ValueError:
            raise PermissionError(f'Write denied (outside workspace): {p}')
    else:
        # Read operations: workspace + home
        allowed = [WORKSPACE_DIR.resolve(), Path.home().resolve()]
        if not any(_is_subpath(p, a) for a in allowed):
            raise PermissionError(f'Access denied: {p}')

    if writing and p.name in PROTECTED_FILES:
        raise PermissionError(f'Protected file: {p.name}')
    return p


def _is_private_url(url: str) :
    """Check if URL resolves to a private/internal IP. Returns (blocked, reason).
    Note: DNS rebinding mitigation — we check resolved IPs before connecting.
    urllib will re-resolve, but attacker needs sub-second DNS TTL flip which is rare.
    """
    import ipaddress, socket
    from urllib.parse import urlparse
    hostname = urlparse(url).hostname or ''
    if not hostname:
        return True, 'No hostname'
    # Quick string check for known dangerous hosts
    _BLOCKED_HOSTS = frozenset([
        'metadata.google.internal', '169.254.169.254', 'metadata.internal',
        'metadata', 'instance-data', '100.100.100.200',  # AWS/GCP/Azure metadata
    ])
    if hostname in _BLOCKED_HOSTS or hostname.endswith('.internal'):
        return True, f'Blocked metadata endpoint: {hostname}'
    try:
        # Resolve ALL addresses (IPv4 + IPv6)
        addrs = socket.getaddrinfo(hostname, None, socket.AF_UNSPEC, socket.SOCK_STREAM)
        for family, _, _, _, sockaddr in addrs:
            ip = ipaddress.ip_address(sockaddr[0])
            if ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_reserved:
                return True, f'Internal IP blocked: {hostname} → {ip}'
    except socket.gaierror:
        return True, f'DNS resolution failed: {hostname}'
    return False, ''


def _is_subpath(path: Path, parent: Path) -> bool:
    """Check if path is under parent (safe, no startswith tricks)."""
    try:
        path.relative_to(parent)
        return True
    except ValueError:
        return False


def _legacy_execute(name: str, args: dict) -> str:
    """Legacy tool execution — used by tools_media.py bridge for not-yet-extracted tools."""
    return _execute_inner(name, args)


def execute_tool(name: str, args: dict) -> str:
    """Execute a tool and return result string. Auto-dispatches to remote node if available."""
    audit_log('tool_exec', f'{name}: {json.dumps(args, ensure_ascii=False)[:200]}')

    # Try remote node dispatch first (if gateway has registered nodes)
    try:
        from .nodes import gateway
        if gateway._nodes:
            result = gateway.dispatch_auto(name, args)
            if result and 'error' not in result:
                return result.get('result', str(result))  # type: ignore[no-any-return]
    except Exception:
        pass  # Fall through to local execution
    return _execute_inner(name, args)


def _execute_inner(name: str, args: dict) -> str:
    """Inner tool dispatch — legacy if-elif chain."""
    try:
        if name == 'exec':
            cmd = args.get('command', '')
            safe, reason = _is_safe_command(cmd)
            if not safe:
                return f'❌ {reason}'
            timeout = min(args.get('timeout', 30), 120)
            try:
                # Use shell=False by default (safer), shell=True only for pipes/redirects
                import shlex
                needs_shell = any(c in cmd for c in ['|', '>', '<', '&&', '||', ';', '`', '$(' ])
                if needs_shell:
                    run_args = {'args': cmd, 'shell': True}
                else:
                    try:
                        run_args = {'args': shlex.split(cmd), 'shell': False}
                    except ValueError:
                        run_args = {'args': cmd, 'shell': True}
                result = subprocess.run(  # type: ignore[assignment]
                    **run_args, capture_output=True, text=True,
                    timeout=timeout, cwd=str(WORKSPACE_DIR)
                )
                output = result.stdout[-5000:] if result.stdout else ''  # type: ignore[union-attr]
                if result.stderr:  # type: ignore[union-attr]
                    output += f'\n[stderr]: {result.stderr[-2000:]}'  # type: ignore[union-attr, operator]
                if result.returncode != 0:  # type: ignore[union-attr]
                    output += f'\n[exit code]: {result.returncode}'  # type: ignore[union-attr, operator]
                return output or '(no output)'
            except subprocess.TimeoutExpired:
                return f'❌ Timeout ({timeout}s)'

        elif name == 'read_file':
            p = _resolve_path(args['path'])
            if not p.exists():
                return f'❌ File not found: {p}'
            text = p.read_text(encoding='utf-8', errors='replace')
            lines = text.splitlines()
            offset = args.get('offset', 1) - 1
            limit = args.get('limit', len(lines))
            selected = lines[offset:offset + limit]
            return '\n'.join(selected)[:50000]

        elif name == 'write_file':
            p = _resolve_path(args['path'], writing=True)
            p.parent.mkdir(parents=True, exist_ok=True)
            p.write_text(args['content'], encoding='utf-8')
            return f'✅ {p} ({len(args["content"])} chars)'

        elif name == 'edit_file':
            p = _resolve_path(args['path'], writing=True)
            text = p.read_text(encoding='utf-8')
            if args['old_text'] not in text:
                return f'❌ Text not found'
            text = text.replace(args['old_text'], args['new_text'], 1)
            p.write_text(text, encoding='utf-8')
            return f'✅ File edited: {p}'

        elif name == 'web_search':
            api_key = vault.get('brave_api_key')
            if not api_key:
                return '❌ Brave Search API key not found'
            query = urllib.parse.quote(args['query'])
            count = min(args.get('count', 5), 10)
            resp = _http_get(
                f'https://api.search.brave.com/res/v1/web/search?q={query}&count={count}',
                {'Accept': 'application/json', 'X-Subscription-Token': api_key}
            )
            results = []
            for r in resp.get('web', {}).get('results', [])[:count]:
                results.append(f"**{r['title']}**\n{r['url']}\n{r.get('description', '')}\n")
            return '\n'.join(results) or 'No results'

        elif name == 'web_fetch':
            url = args['url']
            max_chars = args.get('max_chars', 10000)
            # SSRF protection: DNS-resolve based private IP check
            blocked, reason = _is_private_url(url)
            if blocked:
                return f'❌ {reason}'
            req = urllib.request.Request(url, headers={
                'User-Agent': 'Mozilla/5.0 (SalmAlm/0.1)'
            })
            with urllib.request.urlopen(req, timeout=15) as resp:
                raw = resp.read().decode('utf-8', errors='replace')
            # HTML to text using html.parser (robust, no regex fragility)
            from html.parser import HTMLParser

            class _TextExtractor(HTMLParser):
                def __init__(self):
                    super().__init__()
                    self._parts: list = []
                    self._skip = False
                    self._skip_tags = {'script', 'style', 'noscript', 'svg'}

                def handle_starttag(self, tag, attrs):
                    """Process an HTML opening tag during text extraction."""
                    if tag.lower() in self._skip_tags:
                        self._skip = True
                    elif tag.lower() in ('br', 'p', 'div', 'li', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'tr'):
                        self._parts.append('\n')

                def handle_endtag(self, tag):
                    """Process an HTML closing tag during text extraction."""
                    if tag.lower() in self._skip_tags:
                        self._skip = False

                def handle_data(self, data):
                    """Process text content during HTML extraction."""
                    if not self._skip:
                        self._parts.append(data)

                def get_text(self) -> str:
                    """Return the extracted plain text from parsed HTML."""
                    return re.sub(r'\n{3,}', '\n\n', ''.join(self._parts)).strip()

            extractor = _TextExtractor()
            extractor.feed(raw)
            return extractor.get_text()[:max_chars]

        elif name == 'memory_read':
            return memory_manager.read(args['file'])[:30000]

        elif name == 'memory_write':
            return memory_manager.write(args['file'], args['content'])

        elif name == 'usage_report':
            report = get_usage_report()
            lines = [f"📊 SalmAlm Usage Report",
                     f"⏱️ Uptime: {report['elapsed_hours']}h",
                     f"📥 Input: {report['total_input']:,} tokens",
                     f"📤 Output: {report['total_output']:,} tokens",
                     f"💰 Total cost: ${report['total_cost']:.4f}", ""]
            for m, d in report.get('by_model', {}).items():
                lines.append(f"  {m}: {d['calls']}calls, ${d['cost']:.4f}")
            return '\n'.join(lines)

        elif name == 'memory_search':
            query = args['query']
            max_results = args.get('max_results', 5)
            results = memory_manager.search(query, max_results)
            if not results:
                return f'No results for: "{query}"'
            out = []
            for score, label, lineno, snippet in results:  # type: ignore[misc]
                out.append(f'📍 {label}#{lineno} (similarity:{score:.3f})\n{snippet}\n')  # type: ignore[has-type]
            return '\n'.join(out)

        elif name == 'sub_agent':
            action = args.get('action', 'list')
            if action == 'spawn':
                task = args.get('task', '')
                if not task:
                    return '❌ Task is required'
                model = args.get('model')
                agent_id = SubAgent.spawn(task, model=model)
                return f'🤖 Sub-agent spawned: [{agent_id}]\nTask: {task[:100]}\nWill notify on completion.'
            elif action == 'list':
                agents = SubAgent.list_agents()
                if not agents:
                    return '📋 No running sub-agents.'
                lines = []
                for a in agents:
                    icon = '🟢' if a['status'] == 'running' else '✅' if a['status'] == 'completed' else '❌'
                    lines.append(f'{icon} [{a["id"]}] {a["task"]} — {a["status"]}')
                return '\n'.join(lines)
            elif action == 'result':
                agent_id = args.get('agent_id', '')
                info = SubAgent.get_result(agent_id)
                if 'error' in info:
                    return f'❌ {info["error"]}'
                status = info['status']
                if status == 'running':
                    return f'⏳ [{agent_id}] Still running.\nStarted: {info["started"]}'
                result = info.get('result', '(no result)')
                return f'{"✅" if status == "completed" else "❌"} [{agent_id}] {status}\nStarted: {info["started"]}\nFinished: {info["completed"]}\n\n{result[:3000]}'
            elif action == 'send':
                agent_id = args.get('agent_id', '')
                message = args.get('message', '')
                if not agent_id or not message:
                    return '❌ agent_id and message are required'
                result = SubAgent.send_message(agent_id, message)  # type: ignore[assignment]
                return result  # type: ignore[return-value]
            return f'❌ Unknown action: {action}'

        elif name == 'skill_manage':
            action = args.get('action', 'list')
            if action == 'list':
                skills = SkillLoader.scan()
                if not skills:
                    return '📚 No skills registered.\nCreate a skill directory in skills/ and add SKILL.md.'
                lines = []
                for s in skills:
                    lines.append(f'📚 **{s["name"]}** ({s["dir_name"]})\n   {s["description"]}\n   Size: {s["size"]}chars')
                return '\n'.join(lines)
            elif action == 'load':
                skill_name = args.get('skill_name', '')
                content = SkillLoader.load(skill_name)
                if not content:
                    return f'❌ Skill "{skill_name}" not found'
                return f'📚 Skill loaded: {skill_name}\n\n{content[:5000]}'
            elif action == 'match':
                query = args.get('query', '')
                content = SkillLoader.match(query)
                if not content:
                    return 'No matching skill found.'
                return f'📚 Auto-matched skill:\n\n{content[:5000]}'
            elif action == 'install':
                url = args.get('url', '')
                if not url:
                    return '❌ url is required (Git URL or GitHub shorthand user/repo)'
                return SkillLoader.install(url)
            elif action == 'uninstall':
                skill_name = args.get('skill_name', '')
                if not skill_name:
                    return '❌ skill_name is required'
                return SkillLoader.uninstall(skill_name)
            return f'❌ Unknown action: {action}'

        elif name == 'image_generate':
            prompt = args['prompt']
            provider = args.get('provider', 'xai')
            size = args.get('size', '1024x1024')
            save_dir = WORKSPACE_DIR / 'uploads'
            save_dir.mkdir(exist_ok=True)
            fname = f"gen_{int(time.time())}.png"
            save_path = save_dir / fname

            if provider == 'xai':
                api_key = vault.get('xai_api_key')
                if not api_key:
                    return '❌ xAI API key not found'
                resp = _http_post(
                    'https://api.x.ai/v1/images/generations',
                    {'Authorization': f'Bearer {api_key}', 'Content-Type': 'application/json'},
                    {'model': 'aurora', 'prompt': prompt, 'n': 1, 'size': size,
                     'response_format': 'b64_json'}
                )
                import base64 as b64mod
                img_data = b64mod.b64decode(resp['data'][0]['b64_json'])
                save_path.write_bytes(img_data)
            else:
                api_key = vault.get('openai_api_key')
                if not api_key:
                    return '❌ OpenAI API key not found'
                resp = _http_post(
                    'https://api.openai.com/v1/images/generations',
                    {'Authorization': f'Bearer {api_key}', 'Content-Type': 'application/json'},
                    {'model': 'gpt-image-1', 'prompt': prompt, 'n': 1, 'size': size,
                     'output_format': 'b64_json'}
                )
                import base64 as b64mod
                img_data = b64mod.b64decode(resp['data'][0]['b64_json'])
                save_path.write_bytes(img_data)

            size_kb = len(img_data) / 1024
            log.info(f"[ART] Image generated: {fname} ({size_kb:.1f}KB)")
            return f'✅ Image generated: uploads/{fname} ({size_kb:.1f}KB)\nPrompt: {prompt}'

        elif name == 'image_analyze':
            image_path = args['image_path']
            question = args.get('question', 'Describe this image in detail.')
            import base64 as b64mod
            # Load image
            if image_path.startswith('http://') or image_path.startswith('https://'):
                image_url = image_path
                content_parts = [
                    {'type': 'image_url', 'image_url': {'url': image_url}},
                    {'type': 'text', 'text': question}
                ]
            else:
                img_path = Path(image_path)
                if not img_path.is_absolute():
                    img_path = WORKSPACE_DIR / img_path
                if not img_path.exists():
                    return f'❌ Image not found: {image_path}'
                ext = img_path.suffix.lower()
                mime_map = {'.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif', '.webp': 'image/webp'}
                mime = mime_map.get(ext, 'image/png')
                img_b64 = b64mod.b64encode(img_path.read_bytes()).decode()
                content_parts = [
                    {'type': 'image_url', 'image_url': {'url': f'data:{mime};base64,{img_b64}'}},
                    {'type': 'text', 'text': question}
                ]
            # Try OpenAI first, then Anthropic
            api_key = vault.get('openai_api_key')
            if api_key:
                resp = _http_post(
                    'https://api.openai.com/v1/chat/completions',
                    {'Authorization': f'Bearer {api_key}', 'Content-Type': 'application/json'},
                    {'model': 'gpt-4o', 'messages': [{'role': 'user', 'content': content_parts}], 'max_tokens': 1000}
                )
                return resp['choices'][0]['message']['content']  # type: ignore[no-any-return]
            api_key = vault.get('anthropic_api_key')
            if api_key:
                # Convert to Anthropic format
                img_source = content_parts[0]['image_url']['url']
                if img_source.startswith('data:'):
                    media_type = img_source.split(';')[0].split(':')[1]
                    data = img_source.split(',')[1]
                    img_block = {'type': 'image', 'source': {'type': 'base64', 'media_type': media_type, 'data': data}}
                else:
                    img_block = {'type': 'image', 'source': {'type': 'url', 'url': img_source}}
                resp = _http_post(
                    'https://api.anthropic.com/v1/messages',
                    {'x-api-key': api_key, 'Content-Type': 'application/json', 'anthropic-version': '2023-06-01'},
                    {'model': 'claude-sonnet-4-20250514', 'max_tokens': 1000,
                     'messages': [{'role': 'user', 'content': [img_block, {'type': 'text', 'text': question}]}]}
                )
                return resp['content'][0]['text']  # type: ignore[no-any-return]
            return '❌ No vision API key found (need OpenAI or Anthropic)'

        elif name == 'tts':
            text = args['text']
            voice = args.get('voice', 'nova')
            api_key = vault.get('openai_api_key')
            if not api_key:
                return '❌ OpenAI API key not found'
            save_dir = WORKSPACE_DIR / 'uploads'
            save_dir.mkdir(exist_ok=True)
            fname = f"tts_{int(time.time())}.mp3"
            save_path = save_dir / fname
            data = json.dumps({'model': 'tts-1', 'input': text, 'voice': voice}).encode()
            req = urllib.request.Request(
                'https://api.openai.com/v1/audio/speech',
                data=data,
                headers={'Authorization': f'Bearer {api_key}',
                         'Content-Type': 'application/json'},
                method='POST'
            )
            with urllib.request.urlopen(req, timeout=30) as resp:
                audio = resp.read()
            save_path.write_bytes(audio)
            size_kb = len(audio) / 1024
            log.info(f"[AUDIO] TTS generated: {fname} ({size_kb:.1f}KB)")
            return f'✅ TTS generated: uploads/{fname} ({size_kb:.1f}KB)\nText: {text[:100]}'

        elif name == 'stt':
            api_key = vault.get('openai_api_key')
            if not api_key:
                return '❌ OpenAI API key not found'
            audio_path = args.get('audio_path', '')
            audio_b64 = args.get('audio_base64', '')
            lang = args.get('language', 'ko')
            if audio_path:
                fpath = Path(audio_path).expanduser()
                if not fpath.exists():
                    return f'❌ File not found: {audio_path}'
                audio_data = fpath.read_bytes()
                fname = fpath.name
            elif audio_b64:
                audio_data = base64.b64decode(audio_b64)
                fname = 'audio.webm'
            else:
                return '❌ Provide audio_path or audio_base64'
            # Build multipart form
            boundary = secrets.token_hex(16)
            body = b''
            body += f'--{boundary}\r\nContent-Disposition: form-data; name="file"; filename="{fname}"\r\nContent-Type: application/octet-stream\r\n\r\n'.encode()
            body += audio_data
            body += f'\r\n--{boundary}\r\nContent-Disposition: form-data; name="model"\r\n\r\nwhisper-1'.encode()
            body += f'\r\n--{boundary}\r\nContent-Disposition: form-data; name="language"\r\n\r\n{lang}'.encode()
            body += f'\r\n--{boundary}--\r\n'.encode()
            req = urllib.request.Request(
                'https://api.openai.com/v1/audio/transcriptions',
                data=body,
                headers={'Authorization': f'Bearer {api_key}',
                         'Content-Type': f'multipart/form-data; boundary={boundary}'},
                method='POST'
            )
            with urllib.request.urlopen(req, timeout=30) as resp:
                result = json.loads(resp.read())
            text = result.get('text', '')
            log.info(f"[MIC] STT transcribed: {len(text)} chars")
            return f'🎤 Transcription:\n{text}'

        elif name == 'python_eval':
            code = args.get('code', '')
            timeout_sec = min(args.get('timeout', 15), 30)
            # Block dangerous patterns in code
            _EVAL_BLOCKLIST = [
                'import os', 'import sys', 'import subprocess', 'import shutil',
                '__import__', 'eval(', 'exec(', 'compile(', 'open(',
                'os.system', 'os.popen', 'os.exec', 'os.spawn', 'os.remove', 'os.unlink',
                'shutil.rmtree', 'pathlib', '.vault', 'audit.db', 'auth.db',
                'import socket', 'import http', 'import urllib', 'import requests',
                'getattr(', 'globals(', 'locals(', '__builtins__', 'vars(',
                'breakpoint(', 'help(', 'input(', 'exit(', 'quit(',
                '__class__', '__subclasses__', '__bases__', '__mro__',
                'importlib', 'ctypes', 'signal',
            ]
            code_lower = code.lower().replace(' ', '').replace('\t', '')
            for blocked in _EVAL_BLOCKLIST:
                if blocked.lower().replace(' ', '') in code_lower:
                    return f'❌ Security blocked: `{blocked}` not allowed. python_eval is for computation only.'
            # Additional pattern-based checks (catches string concat bypass)
            import re as _re
            if _re.search(r'__\w+__', code):
                # Allow __name__ style but block dangerous dunders
                _dangerous_dunders = ['__import__', '__builtins__', '__class__',
                                       '__subclasses__', '__bases__', '__mro__', '__loader__']
                for dd in _dangerous_dunders:
                    if dd in code.lower():
                        return f'❌ Security blocked: `{dd}` not allowed.'
            # Execute in isolated subprocess (no network, limited imports)
            wrapper = f'''
import json, math, re, statistics, collections, itertools, functools, datetime, hashlib, base64, random, string, textwrap, csv, io
_result = None
try:
    exec({repr(code)})
except Exception as e:
    _result = f"Error: {{type(e).__name__}}: {{e}}"
if _result is not None:
    print(json.dumps({{"result": str(_result)[:10000]}}))
else:
    print(json.dumps({{"result": "(no _result set)"}}))
'''
            # Resource limits (Linux only — graceful no-op on Windows/macOS)
            def _set_limits():
                try:
                    import resource
                    resource.setrlimit(resource.RLIMIT_CPU, (timeout_sec, timeout_sec))
                    resource.setrlimit(resource.RLIMIT_AS, (512 * 1024 * 1024, 512 * 1024 * 1024))  # 512MB
                    resource.setrlimit(resource.RLIMIT_NOFILE, (50, 50))  # fd limit
                    resource.setrlimit(resource.RLIMIT_NPROC, (10, 10))  # fork bomb prevention
                    resource.setrlimit(resource.RLIMIT_FSIZE, (10 * 1024 * 1024, 10 * 1024 * 1024))  # 10MB file write limit
                except Exception:
                    pass  # Windows or unsupported

            try:
                _kwargs: dict = dict(
                    capture_output=True, text=True,
                    timeout=timeout_sec, cwd=str(WORKSPACE_DIR),
                )
                if sys.platform != 'win32':
                    _kwargs['preexec_fn'] = _set_limits
                result = subprocess.run(  # type: ignore[assignment]
                    [sys.executable, '-c', wrapper], **_kwargs
                )
                if result.returncode == 0 and result.stdout.strip():  # type: ignore[union-attr]
                    try:
                        data = json.loads(result.stdout.strip())  # type: ignore[union-attr]
                        output = data.get('result', result.stdout)  # type: ignore[union-attr]
                    except json.JSONDecodeError:
                        output = result.stdout[-5000:]  # type: ignore[union-attr]
                else:
                    output = result.stdout[-3000:] if result.stdout else ''  # type: ignore[union-attr]
                if result.stderr:  # type: ignore[union-attr]
                    output += f'\n[stderr]: {result.stderr[-2000:]}'  # type: ignore[union-attr, operator]
                return output or '(no output)'
            except subprocess.TimeoutExpired:
                return f'❌ Python execution timeout ({timeout_sec}s)'

        elif name == 'system_monitor':
            detail = args.get('detail', 'overview')
            lines = []
            try:
                if detail in ('overview', 'cpu'):
                    cpu_count = os.cpu_count() or 1
                    try:
                        load = os.getloadavg()
                        lines.append(f'🖥️ CPU: {cpu_count}cores, load: {load[0]:.2f} / {load[1]:.2f} / {load[2]:.2f} (1/5/15min)')
                    except (OSError, AttributeError):
                        lines.append(f'🖥️ CPU: {cpu_count}cores')
                if detail in ('overview', 'memory'):
                    try:
                        mem = subprocess.run(['free', '-h'], capture_output=True, text=True, timeout=5)
                        if mem.stdout:
                            for l in mem.stdout.strip().split('\n'):
                                lines.append(f'💾 {l}')
                    except (FileNotFoundError, OSError):
                        pass
                if detail in ('overview', 'disk'):
                    try:
                        disk = subprocess.run(['df', '-h', '/'], capture_output=True, text=True, timeout=5)
                        if disk.stdout:
                            for l in disk.stdout.strip().split('\n'):
                                lines.append(f'💿 {l}')
                    except (FileNotFoundError, OSError):
                        pass
                if detail in ('overview', 'network'):
                    try:
                        net = subprocess.run(['ss', '-s'], capture_output=True, text=True, timeout=5)
                        if net.stdout:
                            lines.append(f'🌐 Network:')
                            for l in net.stdout.strip().split('\n')[:5]:
                                lines.append(f'   {l}')
                    except (FileNotFoundError, OSError):
                        pass
                if detail == 'processes':
                    try:
                        ps = subprocess.run(['ps', 'aux', '--sort=-rss'], capture_output=True, text=True, timeout=5)
                    except (FileNotFoundError, OSError):
                        ps = None
                    if ps and ps.stdout:
                        for l in ps.stdout.strip().split('\n')[:20]:
                            lines.append(l)
                if detail in ('overview',):
                    try:
                        uptime = subprocess.run(['uptime', '-p'], capture_output=True, text=True, timeout=5)
                        if uptime.stdout:
                            lines.append(f'⏱️ Uptime: {uptime.stdout.strip()}')
                    except (FileNotFoundError, OSError):
                        pass
                    # Python process info
                    mem_mb = 0
                    if _resource_mod:
                        mem_mb = _resource_mod.getrusage(_resource_mod.RUSAGE_SELF).ru_maxrss / 1024  # type: ignore[assignment]
                    lines.append(f'🐍 SalmAlm memory: {mem_mb:.1f}MB')
                    lines.append(f'📂 Sessions: {len(_sessions)}')
            except Exception as e:
                lines.append(f'❌ Monitor error: {e}')
            return '\n'.join(lines) or 'No info'

        elif name == 'http_request':
            method = args.get('method', 'GET').upper()
            url = args.get('url', '')
            headers = args.get('headers', {})
            body_str = args.get('body', '')
            timeout_sec = min(args.get('timeout', 15), 60)
            if not url:
                return '❌ URL is required'
            # SSRF protection: DNS-resolve based private IP check
            blocked, reason = _is_private_url(url)
            if blocked:
                return f'❌ {reason}'
            headers.setdefault('User-Agent', f'SalmAlm/{VERSION}')
            data = body_str.encode('utf-8') if body_str else None
            try:
                req = urllib.request.Request(url, data=data, headers=headers, method=method)
                with urllib.request.urlopen(req, timeout=timeout_sec) as resp:
                    status = resp.status
                    resp_headers = dict(resp.headers)
                    raw = resp.read()
                # Try JSON
                try:
                    body_json = json.loads(raw)
                    body_out = json.dumps(body_json, ensure_ascii=False, indent=2)[:8000]
                except (json.JSONDecodeError, UnicodeDecodeError):
                    body_out = raw.decode('utf-8', errors='replace')[:8000]
                header_str = '\n'.join(f'  {k}: {v}' for k, v in list(resp_headers.items())[:10])
                return f'HTTP {status}\nHeaders:\n{header_str}\n\nBody:\n{body_out}'
            except urllib.error.HTTPError as e:
                body = e.read().decode('utf-8', errors='replace')[:3000]  # type: ignore[assignment]
                return f'HTTP {e.code} {e.reason}\n{body}'  # type: ignore[str-bytes-safe]
            except Exception as e:
                return f'❌ Request error: {e}'

        elif name == 'screenshot':
            region = args.get('region', 'full')
            fname = f'screenshot_{int(time.time())}.png'
            fpath = WORKSPACE_DIR / 'uploads' / fname
            fpath.parent.mkdir(exist_ok=True)
            try:
                cmd = ['import', '-window', 'root', str(fpath)] if region == 'full' else ['import', '-crop', region, '-window', 'root', str(fpath)]
                # Try scrot first (more common)
                try:
                    if region == 'full':
                        subprocess.run(['scrot', str(fpath)], timeout=10, check=True)
                    else:
                        subprocess.run(['scrot', '-a', region, str(fpath)], timeout=10, check=True)
                except FileNotFoundError:
                    subprocess.run(cmd, timeout=10, check=True)
                size_kb = fpath.stat().st_size / 1024
                return f'✅ Screenshot saved: uploads/{fname} ({size_kb:.1f}KB)'
            except Exception as e:
                return f'❌ Screenshot failed: {e}'

        elif name == 'json_query':
            data_str = args.get('data', '')
            query = args.get('query', '.')
            from_file = args.get('from_file', False)
            if from_file:
                fpath = _resolve_path(data_str)
                data_str = fpath.read_text(encoding='utf-8', errors='replace')
            try:
                result = subprocess.run(  # type: ignore[assignment]
                    ['jq', query],
                    input=data_str, capture_output=True, text=True, timeout=10
                )
                if result.returncode == 0:  # type: ignore[union-attr]
                    return result.stdout[:8000] or '(empty)'  # type: ignore[union-attr]
                return f'❌ jq error: {result.stderr[:500]}'  # type: ignore[union-attr]
            except FileNotFoundError:
                # jq not installed, try Python fallback
                data = json.loads(data_str)
                # Simple dot notation query
                parts = query.strip('.').split('.')
                current = data
                for p in parts:
                    if not p:
                        continue
                    if p.endswith('[]'):
                        p = p[:-2]
                        if p:
                            current = current[p]
                        if isinstance(current, list):
                            current = current
                    elif p.isdigit():
                        current = current[int(p)]
                    else:
                        current = current[p]
                return json.dumps(current, ensure_ascii=False, indent=2)[:8000]

        elif name == 'diff_files':
            f1 = args.get('file1', '')
            f2 = args.get('file2', '')
            ctx = args.get('context_lines', 3)
            import difflib
            # If paths exist, read them
            try:
                p1 = _resolve_path(f1)
                text1 = p1.read_text(encoding='utf-8', errors='replace').splitlines()
                label1 = f1
            except Exception:
                text1 = f1.splitlines()
                label1 = 'text1'
            try:
                p2 = _resolve_path(f2)
                text2 = p2.read_text(encoding='utf-8', errors='replace').splitlines()
                label2 = f2
            except Exception:
                text2 = f2.splitlines()
                label2 = 'text2'
            diff = list(difflib.unified_diff(text1, text2, fromfile=label1, tofile=label2, n=ctx))
            if not diff:
                return '✅ Files/texts are identical.'
            return '\n'.join(diff[:300])

        elif name == 'clipboard':
            action = args.get('action', 'list')
            slot = args.get('slot', 'default')
            
            # Slot name length limit (100 chars)
            if len(slot) > 100:
                return '❌ Slot name must be under 100 characters'
            
            clip_file = WORKSPACE_DIR / '.clipboard.json'
            
            with _clipboard_lock:  # race condition prevention
                try:
                    clips = json.loads(clip_file.read_text()) if clip_file.exists() else {}
                except Exception:
                    clips = {}

                if action == 'copy':
                    content = args.get('content', '')
                    if not content:
                        return '❌ content is required'
                    if len(clips) >= 50 and slot not in clips:
                        return '❌ Clipboard slot limit exceeded (max 50)'
                    clips[slot] = {
                        'content': content[:50000],
                        'created': datetime.now(KST).isoformat(),
                        'size': len(content[:50000])  # actual saved size
                    }
                    clip_file.write_text(json.dumps(clips, ensure_ascii=False, indent=2))
                    return f'📋 [{slot}] saved ({len(content[:50000])} chars)'

                elif action == 'paste':
                    if slot not in clips:
                        return f'❌ Slot [{slot}] not found. Available: {", ".join(clips.keys()) or "none"}'
                    return clips[slot]['content']  # type: ignore[no-any-return]

                elif action == 'list':
                    if not clips:
                        return '📋 Clipboard is empty.'
                    lines = ['📋 Clipboard:']
                    for slot_name, data in clips.items():
                        preview = data['content'][:60].replace('\n', ' ')
                        if len(data['content']) > 60:
                            preview += "..."
                        lines.append(f'  [{slot_name}] {data["size"]} chars — "{preview}"')
                    return '\n'.join(lines)

                elif action == 'clear':
                    clip_file.write_text('{}')
                    return '🗑️ Clipboard cleared'

                return f'❌ Unknown action: {action}'

        elif name == 'hash_text':
            import hashlib, secrets, string
            action = args.get('action', 'hash')

            if action == 'hash':
                text = args.get('text', '')
                if not text:
                    return '❌ text is required'
                algo = args.get('algorithm', 'sha256')
                algos = {'sha256': hashlib.sha256, 'md5': hashlib.md5, 'sha1': hashlib.sha1,
                         'sha512': hashlib.sha512, 'sha384': hashlib.sha384}
                if algo not in algos:
                    return f'❌ Supported algorithms: {", ".join(algos.keys())}'
                h = algos[algo](text.encode('utf-8')).hexdigest()
                return f'🔐 {algo.upper()}: {h}'  # prevent sensitive data leak

            elif action == 'password':
                length = max(8, min(args.get('length', 16), 128))  # min 8 chars enforced
                charset = string.ascii_letters + string.digits + '!@#$%^&*'
                pw = ''.join(secrets.choice(charset) for _ in range(length))
                return f'🔑 Password ({length}chars): {pw}'

            elif action == 'uuid':
                import uuid as _uuid_mod
                return f'🆔 UUID: {_uuid_mod.uuid4()}'

            elif action == 'token':
                length = min(args.get('length', 32), 256)
                token = secrets.token_hex((length + 1) // 2)[:length]  # handle odd length
                return f'🎫 Token ({len(token)}chars): {token}'

            return f'❌ Unknown action: {action}'

        elif name == 'regex_test':
            pattern = args.get('pattern', '')
            text = args.get('text', '')
            action = args.get('action', 'find')
            flags_str = args.get('flags', '')

            # Parse flags
            flags = 0
            if 'i' in flags_str:
                flags |= re.IGNORECASE
            if 'm' in flags_str:
                flags |= re.MULTILINE
            if 's' in flags_str:
                flags |= re.DOTALL

            try:
                compiled = re.compile(pattern, flags)
            except re.error as e:
                return f'❌ Regex error: {e}'

            # ReDoS defense - isolate via subprocess (cross-platform)
            def _run_regex():
                if action == 'match':
                    m = compiled.fullmatch(text)
                    if m:
                        groups = m.groups()
                        gdict = m.groupdict()
                        result = f'✅ Full match: "{m.group()}"'
                        if groups:
                            result += f'\nGroups: {groups}'
                        if gdict:
                            result += f'\nNamed groups: {gdict}'
                        return result
                    return '❌ No match'

                elif action == 'find':
                    matches = compiled.findall(text)
                    if not matches:
                        return '❌ No matches found'
                    lines = [f'🔍 {len(matches)} found:']
                    for i, m in enumerate(matches[:50], 1):
                        lines.append(f'  {i}. {m}')
                    if len(matches) > 50:
                        lines.append(f'  ... and {len(matches)-50} more')
                    return '\n'.join(lines)

                elif action == 'replace':
                    replacement = args.get('replacement', '')
                    result = compiled.sub(replacement, text)
                    return f'🔄 Replace result:\n{result[:5000]}'

                elif action == 'split':
                    parts = compiled.split(text)
                    lines = [f'✂️ {len(parts)} parts:']
                    for i, p in enumerate(parts[:50], 1):
                        preview = p[:100]
                        if len(p) > 100:
                            preview += "..."
                        lines.append(f'  {i}. "{preview}"')
                    return '\n'.join(lines)

                return f'❌ Unknown action: {action}'

            # Run with timeout using threading
            import concurrent.futures
            with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool:
                try:
                    future = pool.submit(_run_regex)
                    return future.result(timeout=5)  # type: ignore[no-any-return]
                except concurrent.futures.TimeoutError:
                    return '❌ Regex execution timeout (5s)'

        elif name == 'cron_manage':
            from .core import _llm_cron  # type: ignore[attr-defined]
            if not _llm_cron:
                return '❌ LLM cron manager not initialized'
            action = args.get('action', 'list')
            if action == 'list':
                jobs = _llm_cron.list_jobs()
                if not jobs:
                    return '⏰ No scheduled jobs.'
                lines = ['⏰ **Scheduled Jobs:**']
                for j in jobs:
                    status = '✅' if j['enabled'] else '⏸️'
                    lines.append(f"{status} [{j['id']}] {j['name']} — {j['schedule']} (runs: {j['run_count']})")
                return '\n'.join(lines)
            elif action == 'add':
                name_ = args.get('name', 'Untitled')
                prompt = args.get('prompt', '')
                schedule = args.get('schedule', {})
                if not prompt:
                    return '❌ prompt is required'
                if not schedule:
                    return '❌ schedule is required (kind: cron/every/at)'
                model = args.get('model')
                job = _llm_cron.add_job(name_, schedule, prompt, model=model)
                return f"⏰ Job registered: [{job['id']}] {name_}"
            elif action == 'remove':
                job_id = args.get('job_id', '')
                if _llm_cron.remove_job(job_id):
                    return f'⏰ Job removed: {job_id}'
                return f'❌ Job not found: {job_id}'
            elif action == 'toggle':
                job_id = args.get('job_id', '')
                for j in _llm_cron.jobs:
                    if j['id'] == job_id:
                        j['enabled'] = not j['enabled']
                        _llm_cron.save_jobs()
                        return f"⏰ {j['name']}: {'enabled' if j['enabled'] else 'disabled'}"
                return f'❌ Job not found: {job_id}'
            return f'❌ Unknown action: {action}'

        elif name == 'plugin_manage':
            from .core import PluginLoader
            action = args.get('action', 'list')
            if action == 'list':
                tools = PluginLoader.get_all_tools()
                plugins = PluginLoader._plugins
                if not plugins:
                    return '🔌 No plugins loaded. Add .py files to plugins/ directory.'
                lines = ['🔌 **Plugins:**']
                for name_, info in plugins.items():
                    lines.append(f"  📦 {name_} — {len(info['tools'])} tools ({info['path']})")
                    for t in info['tools']:
                        lines.append(f"    🔧 {t['name']}: {t['description'][:60]}")
                return '\n'.join(lines)
            elif action == 'reload':
                count = PluginLoader.reload()
                return f'🔌 Plugins reloaded: {count} tools loaded'
            return f'❌ Unknown action: {action}'

        elif name == 'browser':
            import asyncio
            from .browser import browser

            def _run_async(coro):
                """Safely run async coroutine from sync context (ThreadPool)."""
                try:
                    loop = asyncio.get_running_loop()
                    # Already in async context — use new thread with new loop
                    import concurrent.futures
                    with concurrent.futures.ThreadPoolExecutor(1) as pool:
                        return pool.submit(lambda: asyncio.run(coro)).result(timeout=30)
                except RuntimeError:
                    # No running loop — safe to use asyncio.run
                    return asyncio.run(coro)

            action = args.get('action', 'status')
            if action == 'status':
                return json.dumps(browser.get_status(), ensure_ascii=False)
            elif action == 'connect':
                ok = _run_async(browser.connect())
                return '🌐 Browser connected' if ok else '❌ Connection failed. Check Chrome --remote-debugging-port=9222'
            elif action == 'navigate':
                url = args.get('url', '')
                if not url:
                    return '❌ url is required'
                result = _run_async(browser.navigate(url))
                return f'🌐 Navigated: {url}\n{json.dumps(result, ensure_ascii=False)}'
            elif action == 'text':
                text = _run_async(browser.get_text())
                return text[:5000] if text else '(empty page or not connected)'
            elif action == 'html':
                html = _run_async(browser.get_html())
                return html[:8000] if html else '(empty page or not connected)'
            elif action == 'screenshot':
                b64 = _run_async(browser.screenshot())
                if b64:
                    import base64 as b64mod
                    save_dir = WORKSPACE_DIR / 'uploads'
                    save_dir.mkdir(exist_ok=True)
                    fname = f'screenshot_{int(time.time())}.png'
                    (save_dir / fname).write_bytes(b64mod.b64decode(b64))
                    return f'📸 Screenshot saved: uploads/{fname} ({len(b64)//1024}KB base64)'
                return '❌ Screenshot failed (not connected?)'
            elif action == 'evaluate':
                expr = args.get('expression', '')
                if not expr:
                    return '❌ expression is required'
                result = _run_async(browser.evaluate(expr))
                return json.dumps(result, ensure_ascii=False, default=str)[:5000]
            elif action == 'click':
                sel = args.get('selector', '')
                ok = _run_async(browser.click(sel))
                return f'✅ Clicked: {sel}' if ok else f'❌ Element not found: {sel}'
            elif action == 'type':
                sel = args.get('selector', '')
                text = args.get('text', '')
                ok = _run_async(browser.type_text(sel, text))
                return f'✅ Input: {sel}' if ok else f'❌ Element not found: {sel}'
            elif action == 'tabs':
                tabs = _run_async(browser.get_tabs())
                return json.dumps(tabs, ensure_ascii=False)
            elif action == 'console':
                logs = browser.get_console_logs(limit=30)
                return '\n'.join(logs) if logs else '(no console logs)'
            elif action == 'pdf':
                b64 = _run_async(browser.pdf())
                if b64:
                    import base64 as b64mod
                    save_dir = WORKSPACE_DIR / 'uploads'
                    save_dir.mkdir(exist_ok=True)
                    fname = f'page_{int(time.time())}.pdf'
                    (save_dir / fname).write_bytes(b64mod.b64decode(b64))
                    return f'📄 PDF saved: uploads/{fname}'
                return '❌ PDF generation failed'
            return f'❌ Unknown action: {action}'

        elif name == 'node_manage':
            from .nodes import node_manager
            action = args.get('action', 'list')
            if action == 'list':
                nodes = node_manager.list_nodes()
                if not nodes:
                    return '📡 No nodes registered. node_manage(action="add", name="...", host="...") to add'
                lines = ['📡 **Nodes:**']
                for n in nodes:
                    lines.append(f"  {'🔗' if n['type']=='ssh' else '🌐'} {n['name']} ({n.get('host', n.get('url', '?'))})")
                return '\n'.join(lines)
            elif action == 'add':
                nname = args.get('name', '')
                ntype = args.get('type', 'ssh')
                if not nname:
                    return '❌ name is required'
                if ntype == 'ssh':
                    host = args.get('host', '')
                    if not host:
                        return '❌ host is required'
                    node_manager.add_ssh_node(nname, host, user=args.get('user', 'root'),
                                              port=args.get('port', 22), key=args.get('key'))
                    return f'📡 SSH node added: {nname}'
                elif ntype == 'http':
                    url = args.get('url', '')
                    if not url:
                        return '❌ url is required'
                    node_manager.add_http_node(nname, url)
                    return f'📡 HTTP node added: {nname}'
                return f'❌ Unknown type: {ntype}'
            elif action == 'remove':
                nname = args.get('name', '')
                if node_manager.remove_node(nname):
                    return f'📡 Node removed: {nname}'
                return f'❌ Node not found: {nname}'
            elif action == 'run':
                nname = args.get('name', '')
                cmd = args.get('command', '')
                if not nname or not cmd:
                    return '❌ name and command are required'
                result = node_manager.run_on(nname, cmd)
                return json.dumps(result, ensure_ascii=False)[:5000]
            elif action == 'status':
                nname = args.get('name')
                if nname:
                    node = node_manager.get_node(nname)
                    if not node:
                        return f'❌ Node not found: {nname}'
                    return json.dumps(node.status(), ensure_ascii=False)[:3000]
                return json.dumps(node_manager.status_all(), ensure_ascii=False)[:5000]
            elif action == 'wake':
                mac = args.get('mac', '')
                if not mac:
                    return '❌ mac is required'
                result = node_manager.wake_on_lan(mac)
                return json.dumps(result, ensure_ascii=False)
            return f'❌ Unknown action: {action}'

        elif name == 'health_check':
            from .stability import health_monitor
            action = args.get('action', 'check')
            if action == 'check':
                report = health_monitor.check_health()
                lines = [f"🏥 **System status: {report['status'].upper()}**",
                         f"⏱️ Uptime: {report['uptime_human']}"]
                sys_info = report.get('system', {})
                if sys_info.get('memory_mb'):
                    lines.append(f"💾 Memory: {sys_info['memory_mb']}MB")
                if sys_info.get('disk_free_mb'):
                    lines.append(f"💿 Disk: {sys_info['disk_free_mb']}MB free ({sys_info.get('disk_pct',0)}% used)")
                lines.append(f"🧵 Threads: {sys_info.get('threads', '?')}")
                lines.append("")
                for comp, status in report['components'].items():
                    icon = '✅' if status.get('status') == 'ok' else '⚠️' if status.get('status') != 'error' else '❌'
                    lines.append(f"  {icon} {comp}: {status.get('status', '?')}")
                return '\n'.join(lines)
            elif action == 'selftest':
                result = health_monitor.startup_selftest()
                lines = [f"🧪 **Self-test: {result['passed']}/{result['total']}**"]
                for mod, status in result['modules'].items():
                    icon = '✅' if status == 'ok' else '❌'
                    lines.append(f"  {icon} {mod}: {status}")
                return '\n'.join(lines)
            elif action == 'recover':
                import asyncio
                try:
                    loop = asyncio.get_running_loop()
                    import concurrent.futures
                    with concurrent.futures.ThreadPoolExecutor(1) as pool:
                        recovered = pool.submit(lambda: asyncio.run(health_monitor.auto_recover())).result(timeout=30)
                except RuntimeError:
                    recovered = asyncio.run(health_monitor.auto_recover())
                if recovered:
                    return f'🔧 Recovery completed: {", ".join(recovered)}'
                return '🔧 No components need recovery (all OK)'
            return f'❌ Unknown action: {action}'

        elif name == 'mcp_manage':
            from .mcp import mcp_manager
            action = args.get('action', 'list')
            if action == 'list':
                servers = mcp_manager.list_servers()
                if not servers:
                    return '🔌 No MCP servers connected. mcp_manage(action="add", name="...", command="...") to add.'
                lines = ['🔌 **MCP Servers:**']
                for s in servers:
                    status = '🟢' if s['connected'] else '🔴'
                    lines.append(f"  {status} {s['name']} — {s['tools']} tools ({' '.join(s['command'])})")
                return '\n'.join(lines)
            elif action == 'add':
                sname = args.get('name', '')
                cmd_str = args.get('command', '')
                if not sname or not cmd_str:
                    return '❌ name and command are required'
                cmd_list = cmd_str.split()
                env = args.get('env', {})
                ok = mcp_manager.add_server(sname, cmd_list, env=env)
                if ok:
                    mcp_manager.save_config()
                    tools_count = len([t for t in mcp_manager.get_all_tools() if t.get('_mcp_server') == sname])
                    return f'🔌 MCP server added: {sname} ({tools_count} tools)'
                return f'❌ MCP server connection failed: {sname}'
            elif action == 'remove':
                sname = args.get('name', '')
                mcp_manager.remove_server(sname)
                mcp_manager.save_config()
                return f'🔌 MCP server removed: {sname}'
            elif action == 'tools':
                all_mcp = mcp_manager.get_all_tools()
                if not all_mcp:
                    return '🔌 No MCP tools (no servers connected)'
                lines = [f'🔌 **MCP Tools ({len(all_mcp)}):**']
                for t in all_mcp:
                    lines.append(f"  🔧 {t['name']}: {t['description'][:80]}")
                return '\n'.join(lines)
            return f'❌ Unknown action: {action}'

        elif name == 'rag_search':
            from .rag import rag_engine
            query = args.get('query', '')
            if not query:
                return '❌ query is required'
            max_results = args.get('max_results', 5)
            results = rag_engine.search(query, max_results=max_results)  # type: ignore[assignment]
            if not results:
                return f'🔍 "{query}" No results for'
            lines = [f'🔍 **"{query}" Results ({len(results)}):**']
            for r in results:
                lines.append(f"\n📄 **{r['source']}** (L{r['line']}, score: {r['score']})")  # type: ignore[index]
                lines.append(r['text'][:300])  # type: ignore[index]
            stats = rag_engine.get_stats()
            lines.append(f"\n📊 Index: {stats['total_chunks']}chunks, {stats['unique_terms']}terms, {stats['db_size_kb']}KB")
            return '\n'.join(lines)

        # ── v0.12 New Tools ──────────────────────────────────────
        elif name == 'google_calendar':
            return _handle_google_calendar(args)

        elif name == 'gmail':
            return _handle_gmail(args)

        elif name == 'reminder':
            return _handle_reminder(args)

        elif name == 'tts_generate':
            return _handle_tts_generate(args)

        elif name == 'workflow':
            return _handle_workflow(args)

        elif name == 'file_index':
            return _handle_file_index(args)

        elif name == 'notification':
            return _handle_notification(args)

        elif name == 'weather':
            return _handle_weather(args)

        elif name == 'rss_reader':
            return _handle_rss_reader(args)

        elif name == 'translate':
            return _handle_translate(args)

        elif name == 'qr_code':
            return _handle_qr_code(args)

        else:
            # Try plugin tools as fallback
            from .core import PluginLoader
            result = PluginLoader.execute(name, args)  # type: ignore[assignment]
            if result is not None:
                return result  # type: ignore[return-value]
            # Try MCP tools as last fallback
            if name.startswith('mcp_'):
                from .mcp import mcp_manager
                mcp_result = mcp_manager.call_tool(name, args)
                if mcp_result is not None:
                    return mcp_result
            return f'❌ Unknown tool: {name}'

    except PermissionError as e:
        return f'❌ Permission denied: {e}'
    except Exception as e:
        log.error(f"Tool error ({name}): {e}")
        return f'❌ Tool error: {str(e)[:200]}'


# ══════════════════════════════════════════════════════════════
#  v0.12 Tool Handlers
# ══════════════════════════════════════════════════════════════

def _google_oauth_headers() -> dict:
    """Get OAuth2 headers for Google APIs. Uses refresh token from vault."""
    token = vault.get('google_access_token') or ''
    refresh = vault.get('google_refresh_token') or ''
    client_id = vault.get('google_client_id') or ''
    client_secret = vault.get('google_client_secret') or ''

    if not token and not refresh:
        raise ValueError(
            'Google API credentials not configured. '
            'Set google_refresh_token, google_client_id, google_client_secret in vault. '
            'Get credentials at https://console.cloud.google.com/apis/credentials')

    # Try existing token first
    if token:
        return {'Authorization': f'Bearer {token}'}

    # Refresh the token
    if refresh and client_id and client_secret:
        data = json.dumps({
            'client_id': client_id,
            'client_secret': client_secret,
            'refresh_token': refresh,
            'grant_type': 'refresh_token',
        }).encode()
        req = urllib.request.Request(
            'https://oauth2.googleapis.com/token',
            data=data,
            headers={'Content-Type': 'application/json'},
            method='POST')
        try:
            with urllib.request.urlopen(req, timeout=10) as resp:
                result = json.loads(resp.read())
                new_token = result.get('access_token', '')
                if new_token:
                    vault.set('google_access_token', new_token)
                    return {'Authorization': f'Bearer {new_token}'}
        except Exception as e:
            raise ValueError(f'OAuth2 refresh failed: {e}')

    raise ValueError('Cannot authenticate with Google. Check vault credentials.')


def _handle_google_calendar(args: dict) -> str:
    """Google Calendar tool handler."""
    action = args.get('action', 'list')
    cal_id = args.get('calendar_id', 'primary')
    base = f'https://www.googleapis.com/calendar/v3/calendars/{cal_id}'
    headers = _google_oauth_headers()

    if action == 'list':
        days = args.get('days', 7)
        now = datetime.utcnow()
        from datetime import timedelta
        time_min = now.isoformat() + 'Z'
        time_max = (now + timedelta(days=days)).isoformat() + 'Z'
        url = f'{base}/events?timeMin={time_min}&timeMax={time_max}&maxResults=20&singleEvents=true&orderBy=startTime'
        req = urllib.request.Request(url, headers=headers)
        with urllib.request.urlopen(req, timeout=15) as resp:
            data = json.loads(resp.read())
        events = data.get('items', [])
        if not events:
            return f'📅 No events in the next {days} days.'
        lines = [f'📅 **Upcoming Events ({len(events)}):**']
        for e in events:
            start = e.get('start', {}).get('dateTime', e.get('start', {}).get('date', '?'))
            end = e.get('end', {}).get('dateTime', e.get('end', {}).get('date', ''))
            summary = e.get('summary', '(no title)')
            loc = e.get('location', '')
            line = f"  • **{summary}** — {start[:16]}"
            if loc:
                line += f" 📍{loc}"
            lines.append(line)
        return '\n'.join(lines)

    elif action == 'create':
        title = args.get('title', '')
        start = args.get('start', '')
        end = args.get('end', '')
        desc = args.get('description', '')
        if not title or not start:
            return '❌ title and start are required for create'
        event = {
            'summary': title,
            'start': {'dateTime': start, 'timeZone': 'Asia/Seoul'},
            'end': {'dateTime': end or start, 'timeZone': 'Asia/Seoul'},
        }
        if desc:
            event['description'] = desc
        body = json.dumps(event).encode()
        req = urllib.request.Request(
            f'{base}/events', data=body,
            headers={**headers, 'Content-Type': 'application/json'},
            method='POST')
        with urllib.request.urlopen(req, timeout=15) as resp:
            result = json.loads(resp.read())
        return f"📅 Event created: **{result.get('summary')}** ({result.get('htmlLink', '')})"

    elif action == 'delete':
        event_id = args.get('event_id', '')
        if not event_id:
            return '❌ event_id is required for delete'
        req = urllib.request.Request(
            f'{base}/events/{event_id}', headers=headers, method='DELETE')
        urllib.request.urlopen(req, timeout=15)
        return f'📅 Event deleted: {event_id}'

    return f'❌ Unknown calendar action: {action}'


def _handle_gmail(args: dict) -> str:
    """Gmail tool handler."""
    action = args.get('action', 'list')
    base = 'https://www.googleapis.com/gmail/v1/users/me'
    headers = _google_oauth_headers()

    if action == 'list' or action == 'search':
        count = min(args.get('count', 10), 50)
        query = args.get('query', '')
        label = args.get('label', 'INBOX')
        params = f'maxResults={count}'
        if query:
            import urllib.parse
            params += f'&q={urllib.parse.quote(query)}'
        elif label:
            params += f'&labelIds={label}'
        url = f'{base}/messages?{params}'
        req = urllib.request.Request(url, headers=headers)
        with urllib.request.urlopen(req, timeout=15) as resp:
            data = json.loads(resp.read())
        messages = data.get('messages', [])
        if not messages:
            return '📧 No messages found.'

        # Fetch headers for each message (batch would be better but keeping it simple)
        lines = [f'📧 **Messages ({len(messages)}):**']
        for msg_ref in messages[:count]:
            msg_url = f'{base}/messages/{msg_ref["id"]}?format=metadata&metadataHeaders=From&metadataHeaders=Subject&metadataHeaders=Date'
            req = urllib.request.Request(msg_url, headers=headers)
            try:
                with urllib.request.urlopen(req, timeout=10) as resp:
                    msg = json.loads(resp.read())
                hdrs = {h['name']: h['value'] for h in msg.get('payload', {}).get('headers', [])}
                subj = hdrs.get('Subject', '(no subject)')
                frm = hdrs.get('From', '?')
                date = hdrs.get('Date', '')[:22]
                snippet = msg.get('snippet', '')[:80]
                lines.append(f"  📩 **{subj}** — {frm[:30]}")
                lines.append(f"     {date} | {snippet}")
                lines.append(f"     ID: `{msg_ref['id']}`")
            except Exception:
                lines.append(f"  📩 ID: {msg_ref['id']} (failed to fetch)")
        return '\n'.join(lines)

    elif action == 'read':
        msg_id = args.get('message_id', '')
        if not msg_id:
            return '❌ message_id is required for read'
        url = f'{base}/messages/{msg_id}?format=full'
        req = urllib.request.Request(url, headers=headers)
        with urllib.request.urlopen(req, timeout=15) as resp:
            msg = json.loads(resp.read())
        hdrs = {h['name']: h['value'] for h in msg.get('payload', {}).get('headers', [])}
        subj = hdrs.get('Subject', '(no subject)')
        frm = hdrs.get('From', '?')
        date = hdrs.get('Date', '')

        # Extract body
        body_text = ''
        payload = msg.get('payload', {})

        def _extract_body(part: dict) -> str:
            """Extract email body text recursively."""
            if part.get('mimeType', '').startswith('text/plain'):
                data = part.get('body', {}).get('data', '')
                if data:
                    return base64.urlsafe_b64decode(data).decode('utf-8', errors='replace')
            for sub in part.get('parts', []):
                result = _extract_body(sub)
                if result:
                    return result
            return ''

        body_text = _extract_body(payload)
        if not body_text and payload.get('body', {}).get('data'):
            body_text = base64.urlsafe_b64decode(payload['body']['data']).decode('utf-8', errors='replace')

        return f"📧 **{subj}**\nFrom: {frm}\nDate: {date}\n\n{body_text[:3000]}"

    elif action == 'send':
        to = args.get('to', '')
        subject = args.get('subject', '')
        body = args.get('body', '')
        if not to or not subject:
            return '❌ to and subject are required for send'
        import email.mime.text
        msg = email.mime.text.MIMEText(body, 'plain', 'utf-8')
        msg['To'] = to
        msg['Subject'] = subject
        raw = base64.urlsafe_b64encode(msg.as_bytes()).decode()
        send_body = json.dumps({'raw': raw}).encode()
        req = urllib.request.Request(
            f'{base}/messages/send', data=send_body,
            headers={**headers, 'Content-Type': 'application/json'},
            method='POST')
        with urllib.request.urlopen(req, timeout=15) as resp:
            result = json.loads(resp.read())
        return f"📧 Email sent to {to} (ID: {result.get('id', '?')})"

    return f'❌ Unknown gmail action: {action}'


# ── Reminder System ──────────────────────────────────────────

_reminders: list = []  # In-memory; persisted to reminders.json
_reminder_lock = threading.Lock()
_reminder_thread_started = False

def _parse_relative_time(s: str) -> datetime:
    """Parse time string into datetime. Supports:
    - Relative: '30m', '2h', '1d', '1w'
    - Korean natural: '내일 오후 3시', '다음주 월요일', '오늘 저녁 7시', '모레 아침'
    - English natural: 'tomorrow 3pm', 'next monday'
    - ISO8601: '2026-02-20T15:00:00'
    """
    import re as _re
    now = datetime.now()
    from datetime import timedelta
    s_stripped = s.strip().lower()

    # Relative: 30m, 2h, 1d, 1w
    m = _re.match(r'^(\d+)\s*(m|min|h|hr|hour|d|day|w|week)s?$', s_stripped)
    if m:
        val = int(m.group(1))
        unit = m.group(2)[0]
        delta = {'m': timedelta(minutes=val), 'h': timedelta(hours=val),
                 'd': timedelta(days=val), 'w': timedelta(weeks=val)}
        return now + delta.get(unit, timedelta(minutes=val))

    # Try ISO format first (before natural language parsing)
    try:
        return datetime.fromisoformat(s)
    except ValueError:
        pass

    # Korean natural language
    s_orig = s.strip()
    day_offset = 0
    hour = None
    minute = 0

    # Day parsing (Korean)
    if '오늘' in s_orig:
        day_offset = 0
    elif '내일' in s_orig:
        day_offset = 1
    elif '모레' in s_orig:
        day_offset = 2
    elif '다음주' in s_orig or 'next week' in s_stripped:
        day_offset = 7
        # 다음주 월요일~일요일
        weekdays_kr = {'월': 0, '화': 1, '수': 2, '목': 3, '금': 4, '토': 5, '일': 6}
        weekdays_en = {'mon': 0, 'tue': 1, 'wed': 2, 'thu': 3, 'fri': 4, 'sat': 5, 'sun': 6}
        for wd, idx in {**weekdays_kr, **weekdays_en}.items():
            if wd in s_orig or wd in s_stripped:
                current_wd = now.weekday()
                days_ahead = (idx - current_wd + 7) % 7
                if days_ahead == 0:
                    days_ahead = 7
                day_offset = days_ahead
                break
    elif 'tomorrow' in s_stripped:
        day_offset = 1

    # Time parsing
    # Korean: 오전/오후 N시 M분, 아침, 점심, 저녁
    if '아침' in s_orig or 'morning' in s_stripped:
        hour = 8
    elif '점심' in s_orig or 'noon' in s_stripped or 'lunch' in s_stripped:
        hour = 12
    elif '저녁' in s_orig or 'evening' in s_stripped:
        hour = 18
    elif '밤' in s_orig or 'night' in s_stripped:
        hour = 21

    # 오후 3시, 오전 10시 30분
    m_kr = _re.search(r'(오전|오후|AM|PM)?\s*(\d{1,2})\s*시\s*(\d{1,2})?\s*분?', s_orig)
    if m_kr:
        period = m_kr.group(1)
        hour = int(m_kr.group(2))
        minute = int(m_kr.group(3) or 0)
        if period in ('오후', 'PM') and hour < 12:
            hour += 12
        elif period in ('오전', 'AM') and hour == 12:
            hour = 0
        elif not period:
            # Infer AM/PM from context: 저녁/밤 = PM
            if ('저녁' in s_orig or '밤' in s_orig) and hour < 12:
                hour += 12
            elif ('오후' in s_orig) and hour < 12:
                hour += 12

    # English: 3pm, 3:30pm, 15:00
    m_en = _re.search(r'(\d{1,2}):?(\d{2})?\s*(am|pm)?', s_stripped)
    if m_en and hour is None:
        hour = int(m_en.group(1))
        minute = int(m_en.group(2) or 0)
        period = m_en.group(3)
        if period == 'pm' and hour < 12:
            hour += 12
        elif period == 'am' and hour == 12:
            hour = 0

    if day_offset > 0 or hour is not None:
        target = now + timedelta(days=day_offset)
        if hour is not None:
            target = target.replace(hour=hour, minute=minute, second=0, microsecond=0)
        elif day_offset > 0:
            target = target.replace(hour=9, minute=0, second=0, microsecond=0)
        return target

    raise ValueError(f'Cannot parse time: {s}. Use ISO8601, relative (30m, 2h), or natural language (내일 오후 3시)')


def _reminders_file() -> Path:
    """Get reminders file path."""
    return WORKSPACE_DIR / 'reminders.json'


def _load_reminders():
    """Load reminders from disk."""
    global _reminders
    fp = _reminders_file()
    if fp.exists():
        try:
            _reminders = json.loads(fp.read_text(encoding='utf-8'))
        except Exception:
            _reminders = []


def _save_reminders():
    """Save reminders to disk."""
    fp = _reminders_file()
    fp.write_text(json.dumps(_reminders, ensure_ascii=False, indent=2, default=str),
                  encoding='utf-8')


def _reminder_check_loop():
    """Background thread that checks for due reminders."""
    while True:
        time.sleep(30)  # Check every 30 seconds
        now = datetime.now()
        with _reminder_lock:
            due = []
            remaining = []
            for r in _reminders:
                try:
                    trigger_time = datetime.fromisoformat(r['time'])
                    if trigger_time <= now:
                        due.append(r)
                    else:
                        remaining.append(r)
                except Exception:
                    remaining.append(r)
            if due:
                _reminders.clear()
                _reminders.extend(remaining)
                _save_reminders()
        # Send notifications for due reminders
        for r in due:
            try:
                _send_notification_impl(
                    f"⏰ Reminder: {r['message']}",
                    title='Reminder',
                    channel='all')
            except Exception as e:
                log.error(f"Reminder notification failed: {e}")
            # Handle repeating reminders
            if r.get('repeat'):
                try:
                    from datetime import timedelta
                    repeat = r['repeat']
                    deltas = {'daily': timedelta(days=1), 'weekly': timedelta(weeks=1),
                              'monthly': timedelta(days=30)}
                    if repeat in deltas:
                        r['time'] = (datetime.fromisoformat(r['time']) + deltas[repeat]).isoformat()
                        with _reminder_lock:
                            _reminders.append(r)
                            _save_reminders()
                except Exception:
                    pass


def _ensure_reminder_thread():
    """Start the reminder background thread if not already running."""
    global _reminder_thread_started
    if not _reminder_thread_started:
        _reminder_thread_started = True
        _load_reminders()
        t = threading.Thread(target=_reminder_check_loop, daemon=True)
        t.start()


def _handle_reminder(args: dict) -> str:
    """Reminder tool handler."""
    _ensure_reminder_thread()
    action = args.get('action', 'set')

    if action == 'set':
        message = args.get('message', '')
        time_str = args.get('time', '')
        if not message or not time_str:
            return '❌ message and time are required'
        trigger_time = _parse_relative_time(time_str)
        reminder = {
            'id': secrets.token_hex(4),
            'message': message,
            'time': trigger_time.isoformat(),
            'repeat': args.get('repeat'),
            'created': datetime.now().isoformat(),
        }
        with _reminder_lock:
            _reminders.append(reminder)
            _save_reminders()
        return f"⏰ Reminder set: **{message}** at {trigger_time.strftime('%Y-%m-%d %H:%M')}" + \
               (f" (repeat: {args['repeat']})" if args.get('repeat') else '')

    elif action == 'list':
        _load_reminders()
        if not _reminders:
            return '⏰ No active reminders.'
        lines = [f'⏰ **Active Reminders ({len(_reminders)}):**']
        for r in sorted(_reminders, key=lambda x: x.get('time', '')):
            repeat_str = f" 🔁{r['repeat']}" if r.get('repeat') else ''
            lines.append(f"  • [{r['id']}] **{r['message']}** — {r['time'][:16]}{repeat_str}")
        return '\n'.join(lines)

    elif action == 'delete':
        rid = args.get('reminder_id', '')
        if not rid:
            return '❌ reminder_id is required'
        with _reminder_lock:
            before = len(_reminders)
            _reminders[:] = [r for r in _reminders if r.get('id') != rid]
            _save_reminders()
            if len(_reminders) < before:
                return f'⏰ Reminder deleted: {rid}'
        return f'❌ Reminder not found: {rid}'

    return f'❌ Unknown reminder action: {action}'


# ── TTS Generate ─────────────────────────────────────────────

def _handle_tts_generate(args: dict) -> str:
    """TTS generation handler — Google TTS (free) or OpenAI TTS."""
    text = args.get('text', '')
    if not text:
        return '❌ text is required'
    provider = args.get('provider', 'google')
    language = args.get('language', 'ko-KR')
    output_path = args.get('output', '')

    if not output_path:
        fname = f"tts_{secrets.token_hex(4)}.mp3"
        output_dir = WORKSPACE_DIR / 'tts_output'
        output_dir.mkdir(exist_ok=True)
        output_path = str(output_dir / fname)

    import urllib.parse  # noqa: must be at function-top to avoid shadowing urllib.request

    if provider == 'google':
        # Google Translate TTS (free, no API key needed)
        # Split long text into chunks (Google TTS limit ~200 chars)
        chunks = []
        while text:
            chunk = text[:200]
            text = text[200:]
            chunks.append(chunk)

        audio_data = b''
        for chunk in chunks:
            encoded = urllib.parse.quote(chunk)
            url = f'https://translate.google.com/translate_tts?ie=UTF-8&q={encoded}&tl={language}&client=tw-ob'
            req = urllib.request.Request(url, headers={
                'User-Agent': 'Mozilla/5.0',
                'Referer': 'https://translate.google.com/',
            })
            try:
                with urllib.request.urlopen(req, timeout=15) as resp:
                    audio_data += resp.read()
            except Exception as e:
                return f'❌ Google TTS failed: {e}'

        Path(output_path).write_bytes(audio_data)
        return f'🔊 TTS generated: {output_path} ({len(audio_data)} bytes, {len(chunks)} chunks)'

    elif provider == 'openai':
        api_key = vault.get('openai_api_key') or ''
        if not api_key:
            return '❌ OpenAI API key not configured in vault'
        voice = args.get('voice', 'alloy')
        body = json.dumps({'model': 'tts-1', 'input': text[:4096], 'voice': voice}).encode()
        req = urllib.request.Request(
            'https://api.openai.com/v1/audio/speech',
            data=body,
            headers={
                'Authorization': f'Bearer {api_key}',
                'Content-Type': 'application/json',
            },
            method='POST')
        try:
            with urllib.request.urlopen(req, timeout=30) as resp:
                audio = resp.read()
            Path(output_path).write_bytes(audio)
            return f'🔊 TTS generated: {output_path} ({len(audio)} bytes, voice={voice})'
        except Exception as e:
            return f'❌ OpenAI TTS failed: {e}'

    return f'❌ Unknown TTS provider: {provider}'


# ── Workflow Engine ──────────────────────────────────────────

_workflows_file = WORKSPACE_DIR / 'workflows.json'

def _load_workflows() -> dict:
    """Load saved workflows."""
    if _workflows_file.exists():
        try:
            return json.loads(_workflows_file.read_text(encoding='utf-8'))
        except Exception:
            pass
    return {}

def _save_workflows(wf: dict):
    """Save workflows."""
    _workflows_file.write_text(json.dumps(wf, ensure_ascii=False, indent=2), encoding='utf-8')


def _handle_workflow(args: dict) -> str:
    """Workflow tool handler — chain multiple tools together."""
    action = args.get('action', 'list')

    if action == 'list':
        wf = _load_workflows()
        if not wf:
            return '🔄 No saved workflows.'
        lines = ['🔄 **Saved Workflows:**']
        for name, data in wf.items():
            steps = data.get('steps', [])
            lines.append(f"  • **{name}** — {len(steps)} steps")
        return '\n'.join(lines)

    elif action == 'save':
        name = args.get('name', '')
        steps = args.get('steps', [])
        if not name or not steps:
            return '❌ name and steps are required for save'
        wf = _load_workflows()
        wf[name] = {'steps': steps, 'created': datetime.now().isoformat()}
        _save_workflows(wf)
        return f'🔄 Workflow saved: **{name}** ({len(steps)} steps)'

    elif action == 'delete':
        name = args.get('name', '')
        wf = _load_workflows()
        if name in wf:
            del wf[name]
            _save_workflows(wf)
            return f'🔄 Workflow deleted: {name}'
        return f'❌ Workflow not found: {name}'

    elif action == 'run':
        name = args.get('name', '')
        steps = args.get('steps', [])
        variables = args.get('variables', {})

        # Load from saved if name provided
        if name and not steps:
            wf = _load_workflows()
            if name not in wf:
                return f'❌ Workflow not found: {name}'
            steps = wf[name].get('steps', [])

        if not steps:
            return '❌ No steps defined'

        # Execute steps sequentially
        context = dict(variables)
        results = []
        for i, step in enumerate(steps):
            tool_name = step.get('tool', '')
            step_args = dict(step.get('args', {}))

            # Variable substitution in args
            for k, v in step_args.items():
                if isinstance(v, str) and v.startswith('$'):
                    var_name = v[1:]
                    if var_name in context:
                        step_args[k] = context[var_name]

            result = execute_tool(tool_name, step_args)
            results.append(f"Step {i+1} ({tool_name}): {result[:200]}")

            # Store output in context
            output_var = step.get('output_var', f'step_{i+1}')
            context[output_var] = result

        return '🔄 **Workflow Complete:**\n' + '\n'.join(results)

    return f'❌ Unknown workflow action: {action}'


# ── File Index ───────────────────────────────────────────────

_file_index: dict = {}  # path -> {mtime, words}
_file_index_lock = threading.Lock()

def _handle_file_index(args: dict) -> str:
    """File index and search handler."""
    action = args.get('action', 'search')

    if action == 'index' or action == 'status':
        target_dir = Path(args.get('path', str(WORKSPACE_DIR)))
        if not target_dir.exists():
            return f'❌ Directory not found: {target_dir}'
        exts = args.get('extensions', 'py,md,txt,json,yaml,yml,toml,cfg,ini,sh,bat,js,ts,html,css')
        ext_set = set(f'.{e.strip()}' for e in exts.split(','))

        count = 0
        with _file_index_lock:
            for fp in target_dir.rglob('*'):
                if fp.is_file() and fp.suffix in ext_set and fp.stat().st_size < 500_000:
                    try:
                        # Skip hidden dirs
                        if any(p.startswith('.') for p in fp.relative_to(target_dir).parts[:-1]):
                            continue
                        content = fp.read_text(encoding='utf-8', errors='replace')[:50000]
                        words = set(re.findall(r'\w+', content.lower()))
                        _file_index[str(fp)] = {
                            'mtime': fp.stat().st_mtime,
                            'words': words,
                            'size': fp.stat().st_size,
                            'preview': content[:200],
                        }
                        count += 1
                    except Exception:
                        pass

        if action == 'status':
            return f'📂 File index: {len(_file_index)} files indexed'
        return f'📂 Indexed {count} files from {target_dir}'

    elif action == 'search':
        query = args.get('query', '')
        if not query:
            return '❌ query is required'
        limit = args.get('limit', 10)

        # Build index if empty
        if not _file_index:
            _handle_file_index({'action': 'index'})

        query_words = set(re.findall(r'\w+', query.lower()))
        if not query_words:
            return '❌ No searchable terms in query'

        # Score files by word overlap
        scored = []
        with _file_index_lock:
            for path, info in _file_index.items():
                overlap = len(query_words & info['words'])
                if overlap > 0:
                    score = overlap / len(query_words)
                    scored.append((score, path, info))

        scored.sort(key=lambda x: x[0], reverse=True)
        results = scored[:limit]

        if not results:
            return f'🔍 No files matching: {query}'

        lines = [f'🔍 **File Search: "{query}" ({len(results)} results):**']
        for score, path, info in results:
            rel = Path(path).relative_to(WORKSPACE_DIR) if path.startswith(str(WORKSPACE_DIR)) else Path(path)
            lines.append(f"  📄 **{rel}** (score: {score:.1%}, {info['size']}B)")
            lines.append(f"     {info['preview'][:100]}...")
        return '\n'.join(lines)

    return f'❌ Unknown file_index action: {action}'


# ── Notification ─────────────────────────────────────────────

def _send_notification_impl(message: str, title: str = '', channel: str = 'all',
                            url: str = '', priority: str = 'normal'):
    """Send notification via available channels."""
    results = []

    # Telegram
    if channel in ('telegram', 'all'):
        try:
            tg = _tg_bot
            if tg:
                owner = vault.get('telegram_owner_id') or ''
                if owner:
                    text = f"🔔 {title}\n{message}" if title else f"🔔 {message}"
                    tg_url = f"https://api.telegram.org/bot{vault.get('telegram_bot_token')}/sendMessage"
                    body = json.dumps({'chat_id': owner, 'text': text}).encode()
                    req = urllib.request.Request(
                        tg_url, data=body,
                        headers={'Content-Type': 'application/json'},
                        method='POST')
                    urllib.request.urlopen(req, timeout=10)
                    results.append('telegram: ✅')
                else:
                    results.append('telegram: ⚠️ no owner_id')
            else:
                results.append('telegram: ⚠️ not configured')
        except Exception as e:
            results.append(f'telegram: ❌ {e}')

    # Desktop notification (cross-platform)
    if channel in ('desktop', 'all'):
        try:
            if sys.platform == 'darwin':
                subprocess.run(['osascript', '-e',
                    f'display notification "{message}" with title "{title or "SalmAlm"}"'],
                    timeout=5, capture_output=True)
                results.append('desktop: ✅')
            elif sys.platform == 'linux':
                subprocess.run(['notify-send', title or 'SalmAlm', message],
                    timeout=5, capture_output=True)
                results.append('desktop: ✅')
            elif sys.platform == 'win32':
                # PowerShell toast notification
                ps_cmd = f'''
                [Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] | Out-Null
                $template = [Windows.UI.Notifications.ToastNotificationManager]::GetTemplateContent([Windows.UI.Notifications.ToastTemplateType]::ToastText02)
                $template.GetElementsByTagName("text")[0].AppendChild($template.CreateTextNode("{title or 'SalmAlm'}")) | Out-Null
                $template.GetElementsByTagName("text")[1].AppendChild($template.CreateTextNode("{message}")) | Out-Null
                $toast = [Windows.UI.Notifications.ToastNotification]::new($template)
                [Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier("SalmAlm").Show($toast)
                '''
                subprocess.run(['powershell', '-Command', ps_cmd],
                    timeout=10, capture_output=True)
                results.append('desktop: ✅')
            else:
                results.append('desktop: ⚠️ unsupported platform')
        except FileNotFoundError:
            results.append('desktop: ⚠️ notification tool not found')
        except Exception as e:
            results.append(f'desktop: ❌ {e}')

    # Webhook
    if channel == 'webhook' and url:
        try:
            body = json.dumps({
                'title': title or 'SalmAlm',
                'message': message,
                'priority': priority,
                'timestamp': datetime.now().isoformat(),
            }).encode()
            req = urllib.request.Request(
                url, data=body,
                headers={'Content-Type': 'application/json'},
                method='POST')
            urllib.request.urlopen(req, timeout=10)
            results.append('webhook: ✅')
        except Exception as e:
            results.append(f'webhook: ❌ {e}')

    return results


def _handle_notification(args: dict) -> str:
    """Notification tool handler."""
    message = args.get('message', '')
    if not message:
        return '❌ message is required'
    title = args.get('title', '')
    channel = args.get('channel', 'all')
    url = args.get('url', '')
    priority = args.get('priority', 'normal')

    results = _send_notification_impl(message, title, channel, url, priority)
    return '🔔 Notification sent:\n  ' + '\n  '.join(results)


# ── Weather ──────────────────────────────────────────────────

def _handle_weather(args: dict) -> str:
    """Weather tool handler using wttr.in (no API key needed)."""
    location = args.get('location', '')
    if not location:
        return '❌ location is required'
    fmt = args.get('format', 'full')
    lang = args.get('lang', 'ko')

    import urllib.parse
    loc_encoded = urllib.parse.quote(location)

    if fmt == 'short':
        url = f'https://wttr.in/{loc_encoded}?format=%l:+%c+%t+%h+%w&lang={lang}'
    elif fmt == 'forecast':
        url = f'https://wttr.in/{loc_encoded}?format=3&lang={lang}'
    else:
        url = f'https://wttr.in/{loc_encoded}?format=j1&lang={lang}'

    req = urllib.request.Request(url, headers={'User-Agent': 'curl/7.0', 'Accept-Language': lang})
    try:
        with urllib.request.urlopen(req, timeout=10) as resp:
            data = resp.read().decode('utf-8', errors='replace')
    except Exception as e:
        return f'❌ Weather fetch failed: {e}'

    if fmt in ('short', 'forecast'):
        return f'🌤️ {data.strip()}'

    # Parse JSON format for full
    try:
        wdata = json.loads(data)
        current = wdata.get('current_condition', [{}])[0]
        area = wdata.get('nearest_area', [{}])[0]
        city = area.get('areaName', [{}])[0].get('value', location)
        country = area.get('country', [{}])[0].get('value', '')

        temp_c = current.get('temp_C', '?')
        feels = current.get('FeelsLikeC', '?')
        humidity = current.get('humidity', '?')
        desc_kr = current.get('lang_ko', [{}])[0].get('value', '') if lang == 'ko' else ''
        desc = desc_kr or current.get('weatherDesc', [{}])[0].get('value', '?')
        wind = current.get('windspeedKmph', '?')
        wind_dir = current.get('winddir16Point', '')
        uv = current.get('uvIndex', '?')
        precip = current.get('precipMM', '0')
        visibility = current.get('visibility', '?')

        lines = [f'🌤️ **{city}** ({country})']
        lines.append(f'  🌡️ {temp_c}°C (체감 {feels}°C) | {desc}')
        lines.append(f'  💧 습도 {humidity}% | 💨 풍속 {wind}km/h {wind_dir}')
        lines.append(f'  ☀️ UV {uv} | 🌧️ 강수 {precip}mm | 👁️ 가시거리 {visibility}km')

        # 3-day forecast
        forecasts = wdata.get('weather', [])[:3]
        if forecasts:
            lines.append('\n📅 **3일 예보:**')
            for day in forecasts:
                date = day.get('date', '?')
                max_t = day.get('maxtempC', '?')
                min_t = day.get('mintempC', '?')
                hourly = day.get('hourly', [])
                desc_day = ''
                if hourly:
                    mid = hourly[len(hourly)//2]
                    desc_day = mid.get('lang_ko', [{}])[0].get('value', '') if lang == 'ko' else ''
                    desc_day = desc_day or mid.get('weatherDesc', [{}])[0].get('value', '')
                lines.append(f'  • {date}: {min_t}~{max_t}°C {desc_day}')

        return '\n'.join(lines)
    except (json.JSONDecodeError, KeyError, IndexError):
        return f'🌤️ {data[:500]}'


# ── RSS Reader ───────────────────────────────────────────────

_feeds_file = WORKSPACE_DIR / 'rss_feeds.json'

def _load_feeds() -> dict:
    """Load RSS feed subscriptions."""
    if _feeds_file.exists():
        try:
            return json.loads(_feeds_file.read_text(encoding='utf-8'))
        except Exception:
            pass
    return {}

def _save_feeds(feeds: dict):
    """Save RSS feed subscriptions."""
    _feeds_file.write_text(json.dumps(feeds, ensure_ascii=False, indent=2), encoding='utf-8')


def _parse_rss(xml_text: str) -> list:
    """Parse RSS/Atom feed XML into list of articles (stdlib only)."""
    from xml.etree import ElementTree as ET
    articles = []
    try:
        root = ET.fromstring(xml_text)
    except ET.ParseError:
        return []

    # RSS 2.0
    for item in root.iter('item'):
        title = item.findtext('title', '').strip()
        link = item.findtext('link', '').strip()
        pub = item.findtext('pubDate', '').strip()
        desc = item.findtext('description', '').strip()
        # Strip HTML from description
        desc = re.sub(r'<[^>]+>', '', desc)[:200]
        articles.append({'title': title, 'link': link, 'date': pub[:25], 'summary': desc})

    # Atom
    if not articles:
        ns = {'atom': 'http://www.w3.org/2005/Atom'}
        for entry in root.iter('{http://www.w3.org/2005/Atom}entry'):
            title = ''
            t = entry.find('{http://www.w3.org/2005/Atom}title')
            if t is not None and t.text:
                title = t.text.strip()
            link = ''
            l = entry.find('{http://www.w3.org/2005/Atom}link')
            if l is not None:
                link = l.get('href', '')
            pub = ''
            p = entry.find('{http://www.w3.org/2005/Atom}published')
            if p is None:
                p = entry.find('{http://www.w3.org/2005/Atom}updated')
            if p is not None and p.text:
                pub = p.text[:25]
            summary = ''
            s = entry.find('{http://www.w3.org/2005/Atom}summary')
            if s is not None and s.text:
                summary = re.sub(r'<[^>]+>', '', s.text)[:200]
            articles.append({'title': title, 'link': link, 'date': pub, 'summary': summary})

    return articles


def _handle_rss_reader(args: dict) -> str:
    """RSS reader tool handler."""
    action = args.get('action', 'fetch')

    if action == 'list':
        feeds = _load_feeds()
        if not feeds:
            return '📰 No subscribed feeds.'
        lines = ['📰 **Subscribed Feeds:**']
        for name, info in feeds.items():
            lines.append(f"  • **{name}** — {info['url']}")
        return '\n'.join(lines)

    elif action == 'subscribe':
        url = args.get('url', '')
        name = args.get('name', '')
        if not url:
            return '❌ url is required for subscribe'
        if not name:
            name = url.split('/')[2] if '/' in url else url[:30]
        feeds = _load_feeds()
        feeds[name] = {'url': url, 'added': datetime.now().isoformat()}
        _save_feeds(feeds)
        return f'📰 Subscribed: **{name}** ({url})'

    elif action == 'unsubscribe':
        name = args.get('name', '')
        feeds = _load_feeds()
        if name in feeds:
            del feeds[name]
            _save_feeds(feeds)
            return f'📰 Unsubscribed: {name}'
        return f'❌ Feed not found: {name}'

    elif action == 'fetch':
        url = args.get('url', '')
        count = args.get('count', 5)

        # If no URL, fetch all subscribed feeds
        if not url:
            feeds = _load_feeds()
            if not feeds:
                return '❌ No URL provided and no subscribed feeds.'
            all_articles = []
            for name, info in feeds.items():
                try:
                    req = urllib.request.Request(info['url'], headers={
                        'User-Agent': 'SalmAlm/1.0 RSS Reader'})
                    with urllib.request.urlopen(req, timeout=10) as resp:
                        xml = resp.read().decode('utf-8', errors='replace')
                    articles = _parse_rss(xml)
                    for a in articles[:3]:
                        a['feed'] = name
                    all_articles.extend(articles[:3])
                except Exception:
                    pass
            if not all_articles:
                return '📰 No articles fetched from subscribed feeds.'
            lines = [f'📰 **Latest Articles ({len(all_articles)}):**']
            for a in all_articles[:count]:
                feed_tag = f" [{a.get('feed', '')}]" if a.get('feed') else ''
                lines.append(f"  📄 **{a['title']}**{feed_tag}")
                if a['date']:
                    lines.append(f"     {a['date']}")
                if a['summary']:
                    lines.append(f"     {a['summary'][:100]}")
                if a['link']:
                    lines.append(f"     🔗 {a['link']}")
            return '\n'.join(lines)

        # Fetch specific URL
        req = urllib.request.Request(url, headers={'User-Agent': 'SalmAlm/1.0 RSS Reader'})
        try:
            with urllib.request.urlopen(req, timeout=10) as resp:
                xml = resp.read().decode('utf-8', errors='replace')
        except Exception as e:
            return f'❌ RSS fetch failed: {e}'

        articles = _parse_rss(xml)
        if not articles:
            return f'📰 No articles found in feed: {url}'

        lines = [f'📰 **Articles ({min(count, len(articles))}):**']
        for a in articles[:count]:
            lines.append(f"  📄 **{a['title']}**")
            if a['date']:
                lines.append(f"     {a['date']}")
            if a['summary']:
                lines.append(f"     {a['summary'][:100]}")
            if a['link']:
                lines.append(f"     🔗 {a['link']}")
        return '\n'.join(lines)

    return f'❌ Unknown rss_reader action: {action}'


# ── Translate ────────────────────────────────────────────────

def _handle_translate(args: dict) -> str:
    """Translation tool handler using Google Translate (free)."""
    text = args.get('text', '')
    target = args.get('target', '')
    source = args.get('source', 'auto')
    if not text or not target:
        return '❌ text and target language are required'

    import urllib.parse
    encoded = urllib.parse.quote(text[:5000])
    url = (f'https://translate.googleapis.com/translate_a/single'
           f'?client=gtx&sl={source}&tl={target}&dt=t&q={encoded}')

    req = urllib.request.Request(url, headers={'User-Agent': 'Mozilla/5.0'})
    try:
        with urllib.request.urlopen(req, timeout=15) as resp:
            data = json.loads(resp.read().decode('utf-8'))
    except Exception as e:
        return f'❌ Translation failed: {e}'

    # Parse response: [[["translated","original",...],...],...]
    try:
        translated_parts = []
        for segment in data[0]:
            if segment[0]:
                translated_parts.append(segment[0])
        translated = ''.join(translated_parts)
        detected = data[2] if len(data) > 2 else source
        lang_names = {
            'ko': '한국어', 'en': 'English', 'ja': '日本語', 'zh-CN': '中文',
            'es': 'Español', 'fr': 'Français', 'de': 'Deutsch', 'ru': 'Русский',
            'pt': 'Português', 'it': 'Italiano', 'vi': 'Tiếng Việt', 'th': 'ไทย',
            'ar': 'العربية', 'hi': 'हिन्दी',
        }
        src_name = lang_names.get(str(detected), str(detected))
        tgt_name = lang_names.get(target, target)
        return f'🌐 **{src_name} → {tgt_name}:**\n{translated}'
    except (IndexError, TypeError):
        return f'❌ Translation parse error: {str(data)[:200]}'


# ── QR Code Generator ────────────────────────────────────────

def _handle_qr_code(args: dict) -> str:
    """QR code generator — pure Python, no dependencies.

    Implements a minimal QR code encoder (Mode Byte, EC level L).
    For complex data, falls back to an online API.
    """
    data = args.get('data', '')
    if not data:
        return '❌ data is required'
    fmt = args.get('format', 'svg')
    size = args.get('size', 10)
    output = args.get('output', '')

    if not output:
        fname = f"qr_{secrets.token_hex(4)}.{'svg' if fmt == 'svg' else 'txt'}"
        output_dir = WORKSPACE_DIR / 'qr_output'
        output_dir.mkdir(exist_ok=True)
        output = str(output_dir / fname)

    # Use Google Charts API for reliable QR generation
    import urllib.parse
    encoded = urllib.parse.quote(data)
    qr_url = f'https://chart.googleapis.com/chart?cht=qr&chs={size*25}x{size*25}&chl={encoded}&choe=UTF-8'

    if fmt == 'text':
        # Generate text-art QR using Unicode blocks
        # Use a simpler approach: fetch from API and convert
        try:
            # Use goqr.me API which returns SVG we can parse
            api_url = f'https://api.qrserver.com/v1/create-qr-code/?size=150x150&data={encoded}&format=svg'
            req = urllib.request.Request(api_url, headers={'User-Agent': 'SalmAlm/1.0'})
            with urllib.request.urlopen(req, timeout=10) as resp:
                svg_data = resp.read().decode('utf-8')
            Path(output).write_text(f'QR Code for: {data}\nGenerate at: {qr_url}\n\n(SVG saved)', encoding='utf-8')
            # Save actual SVG alongside
            svg_path = output.replace('.txt', '.svg')
            Path(svg_path).write_text(svg_data, encoding='utf-8')
            return f'📱 QR code generated:\n  Text: {output}\n  SVG: {svg_path}\n  Data: {data[:50]}'
        except Exception as e:
            return f'❌ QR generation failed: {e}'

    else:  # SVG
        try:
            api_url = f'https://api.qrserver.com/v1/create-qr-code/?size={size*25}x{size*25}&data={encoded}&format=svg'
            req = urllib.request.Request(api_url, headers={'User-Agent': 'SalmAlm/1.0'})
            with urllib.request.urlopen(req, timeout=10) as resp:
                svg_data = resp.read().decode('utf-8')
            Path(output).write_text(svg_data, encoding='utf-8')
            return f'📱 QR code saved: {output} ({len(svg_data)} bytes)\n  Data: {data[:50]}'
        except Exception as e:
            return f'❌ QR generation failed: {e}'
