"""
Worker: Executes tasks in the background with guaranteed completion.

Workers run async, emit progress events, and always complete (success or failure).
The completion queue guarantees the user gets notified.
"""

import asyncio
import contextlib
import json
import os
import platform
import shutil
import tarfile
import tempfile
import time
import urllib.parse
import urllib.request
import zipfile
from pathlib import Path
from typing import Any

from loguru import logger

from kyber.agent.task_registry import Task, TaskRegistry
from kyber.agent.workspace_index import WorkspaceIndex
from kyber.agent.narrator import LiveNarrator
from kyber.meta_messages import looks_like_prompt_leak
from kyber.providers.base import LLMProvider
from kyber.utils.helpers import redact_secrets


class Worker:
    """
    Executes a single task with tools.
    
    Guarantees:
    - Always completes (success, failure, or timeout)
    - Emits progress updates
    - Pushes to completion queue when done
    """

    _opencode_update_lock: asyncio.Lock | None = None
    _CODESEARCH_CHUNK_ERROR_MARKERS = (
        "separator is found, but chunk is longer than limit",
        "chunk is longer than limit",
        "chunk longer than limit",
        "chunk exceeds",
        "text chunk",
    )
    _MAX_BOOTSTRAP_FILE_CHARS = 2000
    _MAX_BOOTSTRAP_TOTAL_CHARS = 6000
    _MAX_WORKSPACE_INDEX_CHARS = 5000
    
    def __init__(
        self,
        task: Task,
        provider: LLMProvider,
        workspace: Path,
        registry: TaskRegistry,
        completion_queue: asyncio.Queue,
        persona_prompt: str,
        model: str | None = None,
        brave_api_key: str | None = None,
        exec_timeout: int = 60,
        timezone: str | None = None,
        workspace_index: WorkspaceIndex | None = None,
        narrator: LiveNarrator | None = None,
    ):
        self.task = task
        self.provider = provider
        self.workspace = workspace
        self.registry = registry
        self.completion_queue = completion_queue
        self.persona_prompt = persona_prompt
        self.model = model or provider.get_default_model()
        self.brave_api_key = brave_api_key
        self.exec_timeout = exec_timeout
        self.timezone = timezone
        self.workspace_index = workspace_index
        self.narrator = narrator

    @staticmethod
    def _result_is_unusable(text: str) -> bool:
        """Check if a final result is unsuitable for user delivery."""
        import re
        raw = text or ""
        t = " ".join(raw.split()).strip()
        if not t:
            return True
        if looks_like_prompt_leak(t):
            return True
        lower = t.lower()
        if len(t.split()) < 6:
            return True
        if any(tok in lower for tok in ["tool call", "tool_calls", "system prompt", "developer message"]):
            return True
        if lower.startswith("error calling llm:") or "litellm." in lower or "badrequest" in lower.replace(" ", ""):
            return True
        # Bot should never ask for permission — it should just do the work.
        if any(phrase in lower for phrase in [
            "would you like me to",
            "shall i ",
            "do you want me to",
            "want me to proceed",
            "like me to proceed",
            "like me to apply",
            "like me to fix",
        ]):
            return True
        # Markdown headers in chat messages = report-style, not conversational.
        # Check the raw text (before whitespace normalization) to preserve newlines.
        if re.search(r'^#{1,3}\s', raw, re.MULTILINE) and raw.count('\n') > 3:
            return True
        # Unfilled template placeholders — brackets containing instruction-like
        # text. Matches both "[Identify the error]" and "[specific issue here]".
        if re.search(r'\[[a-zA-Z][\w\s]{4,}\]', t):
            return True
        # Excessively long responses usually mean the LLM dumped raw file
        # contents or tool output instead of summarizing.
        if len(t) > 2000:
            return True
        return False

    async def _rewrite_result(self, raw_result: str) -> str:
        """Rewrite an unusable result into a concise user-facing message."""
        recent_actions = "\n".join(f"- {a}" for a in (self.task.actions_completed[-12:] or []))
        if not recent_actions:
            recent_actions = "- (none yet)"

        # Build a brief summary of what actually happened from the raw result.
        # Truncate the raw result so the rewrite LLM sees the key info without
        # being tempted to copy-paste it all.
        evidence = raw_result[:1500] if raw_result else "(no output)"

        # Pre-detect failure signals in the evidence so the LLM can't ignore them.
        failure_signals = []
        evidence_lower = evidence.lower()
        if "exit code:" in evidence_lower and "exit code: 0" not in evidence_lower:
            failure_signals.append("COMMAND FAILED (non-zero exit code)")
        if "traceback" in evidence_lower:
            failure_signals.append("PYTHON TRACEBACK DETECTED")
        if "error:" in evidence_lower or "error :" in evidence_lower:
            failure_signals.append("ERROR IN OUTPUT")
        if "not found" in evidence_lower:
            failure_signals.append("FILE OR COMMAND NOT FOUND")

        failure_context = ""
        if failure_signals:
            failure_context = (
                "\n\nFAILURE DETECTED: " + ", ".join(failure_signals) + "\n"
                "The task FAILED. Your message MUST say it failed and why. "
                "Do NOT say it succeeded. Do NOT invent positive results."
            )

        system = (
            f"{self.persona_prompt}\n\n"
            "You are rewriting a task result into a concise status update for the user.\n"
            "Stay in character.\n"
            "RULES:\n"
            "- If the output contains errors, tracebacks, or non-zero exit codes, the task FAILED. Say so.\n"
            "- Do NOT fabricate success. Do NOT say 'executed successfully' unless the output proves it.\n"
            "- Do NOT include API keys, tokens, passwords, or credentials.\n"
            "- Do NOT paste raw file contents, source code, tracebacks, or command output.\n"
            "- Do NOT use markdown headers (##) or structured formatting.\n"
            "- Do NOT ask the user for permission ('Would you like me to...').\n"
            "- Do NOT use placeholder brackets like [error here].\n"
            "- State the specific error in plain language (e.g., 'The script crashed because X').\n"
            "- Keep it under 800 characters. Talk naturally."
        )
        user = (
            f"Task: {self.task.label}\n"
            f"Request: {self.task.description}\n\n"
            f"Actions taken:\n{recent_actions}\n\n"
            f"Raw output (truncated):\n{evidence}\n"
            f"{failure_context}\n\n"
            "Rewrite this into a brief, natural status update."
        )

        try:
            r = await self.provider.chat(
                messages=[
                    {"role": "system", "content": system},
                    {"role": "user", "content": user},
                ],
                tools=None,
                model=self.model,
                max_tokens=600,
            )
            out = (r.content or "").strip()
            if out and not self._result_is_unusable(out):
                return out
        except Exception as e:
            logger.warning(f"Worker [{self.task.id}] result rewrite failed: {e}")

        return raw_result
    
    def _build_worker_prompt(self) -> str:
        """Build the task prompt passed to OpenCode."""
        from kyber.utils.helpers import current_datetime_str

        bootstrap_files = ["AGENTS.md", "USER.md", "TOOLS.md", "IDENTITY.md"]
        bootstrap_parts: list[str] = []
        bootstrap_total = 0
        for fn in bootstrap_files:
            if bootstrap_total >= self._MAX_BOOTSTRAP_TOTAL_CHARS:
                break
            p = self.workspace / fn
            if not p.exists():
                continue
            try:
                content = p.read_text(encoding='utf-8')
                remaining = max(self._MAX_BOOTSTRAP_TOTAL_CHARS - bootstrap_total, 0)
                limit = min(self._MAX_BOOTSTRAP_FILE_CHARS, remaining)
                if limit <= 0:
                    break
                if len(content) > limit:
                    content = content[: max(limit - 1, 0)].rstrip() + "…"
                bootstrap_parts.append(f"## {fn}\n\n{content}")
                bootstrap_total += len(content)
            except Exception:
                continue
        bootstrap = "\n\n---\n\n".join(bootstrap_parts).strip()

        workspace_index = self._get_workspace_index_block()
        workspace_index_text = workspace_index if workspace_index else "(workspace index unavailable)"

        return (
            f"{self.persona_prompt}\n\n"
            "You are executing a background task for the user.\n"
            f"Current time: {current_datetime_str(self.timezone)}\n"
            f"Workspace: {self.workspace}\n\n"
            "Execution requirements:\n"
            "- Perform the requested work directly (edit files, run commands, verify outcomes).\n"
            "- Do not ask for permission; proceed autonomously.\n"
            "- If a command fails, diagnose and fix, then retry when appropriate.\n"
            "- Keep updates concise and factual.\n"
            "- Do not leak secrets (API keys, tokens, passwords).\n"
            "- Final response must be a concise natural-language status update (<1200 chars).\n"
            "- Do not dump raw file contents, tracebacks, or large command output.\n\n"
            f"Workspace bootstrap:\n{bootstrap if bootstrap else '(none)'}\n\n"
            f"{workspace_index_text}\n\n"
            f"Task:\n{self.task.description}\n"
        )

    def _build_worker_prompt_compact(self) -> str:
        """Build a compact worker prompt for retry paths."""
        from kyber.utils.helpers import current_datetime_str

        return (
            f"{self.persona_prompt}\n\n"
            "You are executing a background task for the user.\n"
            f"Current time: {current_datetime_str(self.timezone)}\n"
            f"Workspace: {self.workspace}\n\n"
            "Execution requirements:\n"
            "- Perform the requested work directly.\n"
            "- Do not ask for permission.\n"
            "- Keep updates concise and factual.\n"
            "- Final response must be a concise natural-language status update.\n\n"
            f"Task:\n{self.task.description}\n"
        )

    def _get_workspace_index_block(self) -> str:
        """Get workspace index for injection into system prompt."""
        if not self.workspace_index:
            return ""
        try:
            index = self.workspace_index.get()
            if index:
                if len(index) > self._MAX_WORKSPACE_INDEX_CHARS:
                    index = index[: self._MAX_WORKSPACE_INDEX_CHARS - 1].rstrip() + "…"
                return (
                    "## Workspace File Map\n"
                    "You already know the workspace layout. Skip list_dir unless you need "
                    "a subdirectory not shown here. Jump straight to the work.\n\n"
                    f"{index}"
                )
        except Exception as e:
            logger.debug(f"Failed to get workspace index: {e}")
        return ""
    
    async def run(self) -> None:
        """
        Execute the task. Guaranteed to complete or fail.
        Always pushes to completion queue.
        """
        try:
            self.registry.mark_started(self.task.id)
            start_action = "starting task"
            self.registry.update_progress(self.task.id, iteration=0, current_action=start_action)
            if self.narrator:
                self.narrator.narrate(self.task.id, start_action)
            logger.info(f"Worker started: {self.task.label} [{self.task.id}]")
            
            result = await self._execute()
            
            self.registry.mark_completed(self.task.id, result)
            logger.info(f"Worker completed: {self.task.label} [{self.task.id}]")
            
        except asyncio.CancelledError:
            self.registry.mark_cancelled(self.task.id, "cancelled")
            logger.info(f"Worker cancelled: {self.task.label} [{self.task.id}]")
            raise
        except Exception as e:
            error = str(e)
            self.registry.mark_failed(self.task.id, error)
            logger.error(f"Worker failed: {self.task.label} [{self.task.id}] - {e}")
        
        finally:
            # ALWAYS push to completion queue - guaranteed delivery
            await self.completion_queue.put(self.task)
    
    async def _execute(self) -> str:
        """Execute the task using OpenCode as the worker engine."""
        result = await self._execute_with_opencode()
        result = redact_secrets(result)
        if self._result_is_unusable(result):
            logger.info(f"Worker [{self.task.id}] OpenCode result unusable, rewriting")
            result = await self._rewrite_result(result)
        return redact_secrets(result)

    def _managed_opencode_path(self) -> Path:
        """Path to Kyber-managed OpenCode binary."""
        from kyber.utils.helpers import ensure_dir, get_data_path

        bin_dir = ensure_dir(get_data_path() / "runtime" / "opencode" / "bin")
        name = "opencode.exe" if os.name == "nt" else "opencode"
        return bin_dir / name

    def _managed_opencode_manifest_path(self) -> Path:
        """Path to Kyber-managed OpenCode metadata manifest."""
        from kyber.utils.helpers import ensure_dir, get_data_path

        runtime_dir = ensure_dir(get_data_path() / "runtime" / "opencode")
        return runtime_dir / "manifest.json"

    def _opencode_runtime_home(self) -> Path:
        """Isolated HOME used for deterministic OpenCode execution."""
        from kyber.utils.helpers import ensure_dir, get_data_path

        return ensure_dir(get_data_path() / "runtime" / "opencode" / "home")

    def _opencode_release_asset_name(self) -> str:
        """Compute OpenCode release asset name for current platform."""
        system = platform.system().lower()
        machine = platform.machine().lower()
        arch_alias = {
            "amd64": "x64",
            "x86_64": "x64",
            "x64": "x64",
            "x86-64": "x64",
            "aarch64": "arm64",
            "arm64": "arm64",
        }
        arch = arch_alias.get(machine, machine)

        if system == "darwin":
            if arch not in {"arm64", "x64"}:
                raise RuntimeError(f"Unsupported macOS architecture for OpenCode: {machine}")
            return f"opencode-darwin-{arch}.zip"
        if system == "linux":
            if arch not in {"arm64", "x64"}:
                raise RuntimeError(f"Unsupported Linux architecture for OpenCode: {machine}")
            return f"opencode-linux-{arch}.tar.gz"
        if system == "windows":
            if arch != "x64":
                raise RuntimeError(f"Unsupported Windows architecture for OpenCode: {machine}")
            return "opencode-windows-x64.zip"

        raise RuntimeError(f"Unsupported platform for OpenCode runtime install: {platform.system()}")

    @classmethod
    def _get_opencode_update_lock(cls) -> asyncio.Lock:
        lock = cls._opencode_update_lock
        if lock is None:
            lock = asyncio.Lock()
            cls._opencode_update_lock = lock
        return lock

    @staticmethod
    def _normalize_release_tag(tag: str | None) -> str:
        val = (tag or "").strip().lower()
        if val.startswith("v"):
            val = val[1:]
        return val

    @classmethod
    def _should_install_opencode_release(
        cls,
        *,
        target_exists: bool,
        installed_tag: str | None,
        latest_tag: str | None,
    ) -> bool:
        if not target_exists:
            return True
        latest = cls._normalize_release_tag(latest_tag)
        if not latest:
            return False
        installed = cls._normalize_release_tag(installed_tag)
        if not installed:
            return True
        return installed != latest

    @staticmethod
    def _safe_float(value: Any) -> float | None:
        if value is None:
            return None
        try:
            return float(value)
        except (TypeError, ValueError):
            return None

    def _opencode_update_check_interval_seconds(self) -> int:
        raw = (os.getenv("KYBER_OPENCODE_UPDATE_CHECK_INTERVAL_SECONDS", "") or "").strip()
        if raw:
            try:
                parsed = int(raw)
                if parsed >= 0:
                    return parsed
            except ValueError:
                pass
        return 6 * 60 * 60

    @staticmethod
    def _is_truthy_env(value: str | None) -> bool:
        val = (value or "").strip().lower()
        return val in {"1", "true", "yes", "on"}

    @staticmethod
    def _is_falsy_env(value: str | None) -> bool:
        val = (value or "").strip().lower()
        return val in {"0", "false", "no", "off"}

    def _fast_prompt_mode_enabled(self) -> bool:
        raw = os.getenv("KYBER_OPENCODE_FAST_MODE")
        if raw is None:
            # Default to lean prompts for native-feeling responsiveness.
            return True
        return self._is_truthy_env(raw)

    def _strict_opencode_isolation_enabled(self) -> bool:
        raw = os.getenv("KYBER_OPENCODE_STRICT_ISOLATION")
        if raw is None:
            # Default is native-ish behavior (project config/plugins allowed).
            return False
        if self._is_falsy_env(raw):
            return False
        return self._is_truthy_env(raw)

    def _should_check_opencode_update(self, last_checked_at: float | None, *, now: float | None = None) -> bool:
        interval = self._opencode_update_check_interval_seconds()
        if interval == 0:
            return True
        if last_checked_at is None:
            return True
        if now is None:
            now = time.time()
        return (now - last_checked_at) >= interval

    def _read_opencode_manifest(self) -> dict[str, Any]:
        path = self._managed_opencode_manifest_path()
        if not path.exists():
            return {}
        try:
            data = json.loads(path.read_text(encoding="utf-8"))
            return data if isinstance(data, dict) else {}
        except Exception:
            return {}

    def _write_opencode_manifest(self, manifest: dict[str, Any]) -> None:
        path = self._managed_opencode_manifest_path()
        path.parent.mkdir(parents=True, exist_ok=True)
        tmp = path.with_suffix(".tmp")
        tmp.write_text(json.dumps(manifest, separators=(",", ":")), encoding="utf-8")
        tmp.replace(path)

    def _fetch_latest_opencode_release(self) -> dict[str, str]:
        api_url = "https://api.github.com/repos/anomalyco/opencode/releases/latest"
        req = urllib.request.Request(
            api_url,
            headers={
                "Accept": "application/vnd.github+json",
                "User-Agent": "kyber-worker",
            },
        )
        with urllib.request.urlopen(req, timeout=30) as resp:  # nosec B310
            release = json.loads(resp.read().decode("utf-8"))

        wanted = self._opencode_release_asset_name()
        assets = release.get("assets") or []
        asset = next((a for a in assets if a.get("name") == wanted), None)
        if not asset or not asset.get("browser_download_url"):
            raise RuntimeError(f"OpenCode release asset not found: {wanted}")

        tag = str(release.get("tag_name") or "").strip()
        if not tag:
            raise RuntimeError("OpenCode release metadata missing tag_name")

        url = str(asset["browser_download_url"]).strip()
        if not url:
            raise RuntimeError("OpenCode release metadata missing browser_download_url")

        return {
            "tag": tag,
            "asset_name": wanted,
            "download_url": url,
        }

    def _install_managed_opencode(self, *, release: dict[str, str]) -> Path:
        """Download and install OpenCode binary into Kyber runtime dir."""
        target = self._managed_opencode_path()
        wanted = release.get("asset_name", "")
        url = release.get("download_url", "")
        if not wanted or not url:
            raise RuntimeError("Invalid OpenCode release metadata for install")

        with tempfile.TemporaryDirectory(prefix="kyber-opencode-install-") as td:
            tmp = Path(td)
            archive = tmp / wanted
            dreq = urllib.request.Request(url, headers={"User-Agent": "kyber-worker"})
            with urllib.request.urlopen(dreq, timeout=120) as resp:  # nosec B310
                archive.write_bytes(resp.read())

            extract_dir = tmp / "extract"
            extract_dir.mkdir(parents=True, exist_ok=True)

            if wanted.endswith(".tar.gz"):
                with tarfile.open(archive, "r:gz") as tf:
                    tf.extractall(extract_dir)
            elif wanted.endswith(".zip"):
                with zipfile.ZipFile(archive, "r") as zf:
                    zf.extractall(extract_dir)
            else:
                extract_dir = tmp

            exe_names = {"opencode.exe", "opencode"} if os.name == "nt" else {"opencode"}
            source: Path | None = None
            for p in extract_dir.rglob("*"):
                if p.is_file() and p.name in exe_names:
                    source = p
                    break

            if source is None and archive.is_file() and archive.name in exe_names:
                source = archive
            if source is None:
                raise RuntimeError("Downloaded OpenCode archive did not contain an opencode executable")

            target.parent.mkdir(parents=True, exist_ok=True)
            staged = target.parent / f"{target.name}.new"
            shutil.copy2(source, staged)
            if os.name != "nt":
                staged.chmod(0o755)
            staged.replace(target)
            if os.name != "nt":
                target.chmod(0o755)

        return target

    def _ensure_managed_opencode_sync(self) -> Path:
        target = self._managed_opencode_path()
        manifest = self._read_opencode_manifest()
        now = time.time()
        installed_tag = str(manifest.get("installed_tag") or "").strip()
        last_checked_at = self._safe_float(manifest.get("last_checked_at"))

        should_check_remote = (not target.exists()) or self._should_check_opencode_update(last_checked_at, now=now)
        if not should_check_remote and target.exists():
            return target

        try:
            release = self._fetch_latest_opencode_release()
            latest_tag = release.get("tag", "")
            if self._should_install_opencode_release(
                target_exists=target.exists(),
                installed_tag=installed_tag,
                latest_tag=latest_tag,
            ):
                logger.info(
                    f"Worker [{self.task.id}] updating managed OpenCode "
                    f"{installed_tag or '(unknown)'} -> {latest_tag}"
                )
                target = self._install_managed_opencode(release=release)
                installed_tag = latest_tag
            manifest["latest_tag"] = latest_tag
        except Exception as e:
            if target.exists():
                logger.warning(f"Worker [{self.task.id}] OpenCode update check failed; using installed binary: {e}")
                manifest["last_check_error"] = str(e)[:400]
                manifest["last_checked_at"] = now
                if installed_tag:
                    manifest["installed_tag"] = installed_tag
                with contextlib.suppress(Exception):
                    self._write_opencode_manifest(manifest)
                return target
            raise

        manifest["last_checked_at"] = now
        if installed_tag:
            manifest["installed_tag"] = installed_tag
        manifest.pop("last_check_error", None)
        self._write_opencode_manifest(manifest)
        return target

    async def _ensure_managed_opencode(self) -> Path:
        lock = self._get_opencode_update_lock()
        async with lock:
            return await asyncio.to_thread(self._ensure_managed_opencode_sync)

    async def _resolve_opencode_command_prefix(self) -> list[str]:
        """Resolve how to invoke OpenCode."""
        try:
            installed = await self._ensure_managed_opencode()
            if installed.exists():
                return [str(installed)]
        except Exception as e:
            logger.warning(f"Worker [{self.task.id}] managed OpenCode install failed, trying fallback: {e}")

        opencode_bin = shutil.which("opencode")
        if opencode_bin:
            return [opencode_bin]

        raise RuntimeError(
            "OpenCode runtime unavailable. Kyber failed to provision a managed OpenCode runtime and no "
            "system `opencode` binary is available."
        )

    def _resolve_task_base_url(self) -> str:
        """Resolve OpenAI-compatible base URL for the configured task provider."""
        explicit = (self.provider.api_base or "").strip()
        if explicit:
            return explicit.rstrip("/")

        provider_name = (getattr(self.provider, "provider_name", None) or "").strip().lower()
        defaults = {
            "openai": "https://api.openai.com/v1",
            "openrouter": "https://openrouter.ai/api/v1",
            "deepseek": "https://api.deepseek.com/v1",
            "groq": "https://api.groq.com/openai/v1",
            "gemini": "https://generativelanguage.googleapis.com/v1beta/openai",
            "anthropic": "https://api.anthropic.com/v1",
        }
        return defaults.get(provider_name, "https://api.openai.com/v1")

    def _build_opencode_config_content(
        self,
        *,
        provider_id: str,
        model_id: str,
        base_url: str | None,
        provider_name: str,
        env_key_name: str,
        force_openai_compatible: bool,
        allow_codesearch: bool = True,
        allow_read: bool = True,
    ) -> str:
        """Build isolated OpenCode config for Kyber worker execution."""
        provider_timeout: int | bool
        if self.exec_timeout <= 0:
            # Preserve "no timeout" intent for provider requests.
            provider_timeout = False
        else:
            # Provider round-trips can take significantly longer than shell tool
            # execution, so keep this generous to avoid spurious TimeoutError.
            provider_timeout = max(300_000, int(self.exec_timeout * 1000))

        provider_options: dict[str, Any] = {
            "timeout": provider_timeout,
        }
        if base_url:
            provider_options["baseURL"] = base_url
        if provider_name == "openrouter":
            provider_options["headers"] = {
                "HTTP-Referer": "https://kyber.chat",
                "X-Title": "Kyber",
            }

        provider_cfg: dict[str, Any] = {
            "name": "Kyber Task Provider",
            "env": [env_key_name],
            "options": provider_options,
            "models": {
                model_id: {
                    "id": model_id,
                    "name": model_id,
                    "tool_call": True,
                    "temperature": True,
                },
            },
        }
        if base_url:
            provider_cfg["api"] = base_url
        if force_openai_compatible:
            provider_cfg["npm"] = "@ai-sdk/openai-compatible"

        config = {
            "$schema": "https://opencode.ai/config.json",
            "enabled_providers": [provider_id],
            "model": f"{provider_id}/{model_id}",
            "permission": {
                "read": "allow" if allow_read else "deny",
                "edit": "allow",
                "list": "allow",
                "glob": "allow",
                "grep": "allow",
                "bash": "allow",
                "webfetch": "allow",
                "websearch": "allow",
                "codesearch": "allow" if allow_codesearch else "deny",
                "external_directory": "allow",
                "todowrite": "allow",
                "todoread": "allow",
                "question": "deny",
                "task": "deny",
            },
            "provider": {
                provider_id: provider_cfg,
            },
        }
        return json.dumps(config, separators=(",", ":"))

    @classmethod
    def _is_codesearch_chunk_limit_error(cls, text: str) -> bool:
        lower = (text or "").lower()
        return any(marker in lower for marker in cls._CODESEARCH_CHUNK_ERROR_MARKERS)

    @staticmethod
    def _shorten(value: str, limit: int = 140) -> str:
        text = " ".join((value or "").split()).strip()
        if len(text) <= limit:
            return text
        return text[: limit - 1].rstrip() + "…"

    @staticmethod
    def _site_title_from_webfetch_args(args: dict[str, Any]) -> str:
        """Derive a short, non-link site title from webfetch args."""
        raw_title = " ".join(str(args.get("title", "")).split()).strip()
        if raw_title:
            return raw_title

        raw_url = str(args.get("url", "")).strip()
        if not raw_url:
            return ""

        parsed = urllib.parse.urlparse(raw_url)
        host = (parsed.hostname or "").strip().lower()
        if not host:
            return ""
        if host.startswith("www."):
            host = host[4:]

        host_parts = [p for p in host.split(".") if p]
        if len(host_parts) < 2:
            return host

        known_domains = {
            "github.com": "GitHub",
            "openai.com": "OpenAI",
            "youtube.com": "YouTube",
            "wikipedia.org": "Wikipedia",
            "x.com": "X",
            "twitter.com": "X",
            "reddit.com": "Reddit",
            "stackoverflow.com": "Stack Overflow",
            "docs.python.org": "Python Docs",
        }
        known = known_domains.get(host)
        if known:
            return known

        root_domain = ".".join(host_parts[-2:])
        root_label = known_domains.get(root_domain)
        if root_label is None:
            domain_token = host_parts[-2]
            words = [w for w in domain_token.replace("-", " ").replace("_", " ").split() if w]
            if not words:
                root_label = domain_token
            else:
                root_label = " ".join(w.upper() if len(w) <= 2 else w.capitalize() for w in words)

        subdomain_hints = {
            "docs": "Docs",
            "blog": "Blog",
            "developer": "Developer",
            "developers": "Developer",
            "help": "Help",
            "support": "Support",
            "status": "Status",
            "api": "API",
            "news": "News",
        }
        sub_parts = host_parts[:-2]
        hint = ""
        if sub_parts:
            candidate = sub_parts[-1].lower()
            hint = subdomain_hints.get(candidate, "")

        return f"{root_label} {hint}".strip()

    def _format_opencode_action(self, tool_name: str, args: dict[str, Any]) -> str:
        """Human-readable action for OpenCode tool events."""
        name = (tool_name or "").strip()
        args = args or {}

        if name == "bash":
            cmd = self._shorten(str(args.get("command", "")))
            return f"running `{cmd}`" if cmd else "running a shell command"
        if name == "read":
            p = self._shorten(str(args.get("filePath", "")))
            return f"reading `{p}`" if p else "reading a file"
        if name == "list":
            p = self._shorten(str(args.get("path", "")))
            return f"checking `{p}`" if p else "checking a folder"
        if name == "write":
            p = self._shorten(str(args.get("filePath", "")))
            return f"writing `{p}`" if p else "writing a file"
        if name == "edit":
            p = self._shorten(str(args.get("filePath", "")))
            return f"editing `{p}`" if p else "editing a file"
        if name == "websearch":
            q = self._shorten(str(args.get("query", "")))
            return f"searching for “{q}”" if q else "searching the web"
        if name == "webfetch":
            site_title = self._shorten(self._site_title_from_webfetch_args(args), limit=70)
            return f"opening {site_title}" if site_title else "opening a web page"
        if name == "glob":
            pat = self._shorten(str(args.get("pattern", "")))
            return f"globbing `{pat}`" if pat else "searching files"
        if name == "grep":
            pat = self._shorten(str(args.get("pattern", "")))
            return f"grepping `{pat}`" if pat else "searching content"
        return "making progress"

    @staticmethod
    def _extract_opencode_error(raw: Any) -> str | None:
        if isinstance(raw, dict):
            data = raw.get("data")
            if isinstance(data, dict):
                msg = data.get("message")
                if isinstance(msg, str) and msg.strip():
                    return msg.strip()
            for key in ("message", "name", "code"):
                val = raw.get(key)
                if isinstance(val, str) and val.strip():
                    return val.strip()
            return json.dumps(raw)
        if isinstance(raw, str):
            msg = raw.strip()
            return msg or None
        if raw is None:
            return None
        return str(raw).strip() or None

    async def _execute_with_opencode(self) -> str:
        """Run the task through OpenCode with targeted fallback for known chunk-limit failures."""
        fast_mode = self._fast_prompt_mode_enabled()
        attempts = [
            {"disable_codesearch": False, "compact_prompt": fast_mode, "disable_read": False},
            {"disable_codesearch": True, "compact_prompt": True, "disable_read": True},
        ]
        last_error: Exception | None = None
        for i, attempt in enumerate(attempts):
            try:
                return await self._execute_with_opencode_attempt(
                    disable_codesearch=bool(attempt["disable_codesearch"]),
                    compact_prompt=bool(attempt["compact_prompt"]),
                    disable_read=bool(attempt["disable_read"]),
                )
            except Exception as e:
                last_error = e
                msg = str(e)
                is_chunk_error = self._is_codesearch_chunk_limit_error(msg)
                is_last = i == (len(attempts) - 1)
                if (not is_chunk_error) or is_last:
                    raise
                logger.warning(
                    f"Worker [{self.task.id}] retrying after chunk-limit failure "
                    f"(next attempt {i + 2}/{len(attempts)}): {msg}"
                )

        if last_error:
            raise last_error
        raise RuntimeError("OpenCode execution failed without an explicit error.")

    async def _execute_with_opencode_attempt(
        self,
        *,
        disable_codesearch: bool,
        compact_prompt: bool,
        disable_read: bool,
    ) -> str:
        """Run the task through OpenCode and return the final assistant message."""
        command_prefix = await self._resolve_opencode_command_prefix()
        provider_name = (getattr(self.provider, "provider_name", None) or "openai").strip().lower()
        raw_model = (self.model or self.provider.get_default_model() or "").strip()
        if not raw_model:
            raise RuntimeError("No task model configured for worker.")

        api_key = (self.provider.api_key or "").strip()
        if not api_key:
            raise RuntimeError(f"No API key configured for task provider '{provider_name}'.")

        provider_alias = {
            "gemini": "google",
            "z.ai": "zai",
        }
        canonical_provider = provider_alias.get(provider_name, provider_name)
        known_native = {"openai", "anthropic", "openrouter", "deepseek", "groq", "google"}
        is_custom_provider = bool(getattr(self.provider, "is_custom", False))
        force_openai_compatible = is_custom_provider or canonical_provider not in known_native

        provider_id = "kyber_task" if force_openai_compatible else canonical_provider
        model_id = raw_model
        provider_prefix = f"{canonical_provider}/"
        if not force_openai_compatible and model_id.lower().startswith(provider_prefix):
            model_id = model_id[len(provider_prefix):]
        if canonical_provider == "openrouter" and model_id.lower().startswith("openrouter/"):
            model_id = model_id[len("openrouter/"):]

        base_url = self._resolve_task_base_url() if force_openai_compatible else (self.provider.api_base or None)
        model_selector = f"{provider_id}/{model_id}"

        env = os.environ.copy()
        env_key_name = "KYBER_OPENCODE_TASK_API_KEY"
        env[env_key_name] = api_key
        env["OPENCODE_CONFIG_CONTENT"] = self._build_opencode_config_content(
            provider_id=provider_id,
            model_id=model_id,
            base_url=base_url,
            provider_name=provider_name,
            env_key_name=env_key_name,
            force_openai_compatible=force_openai_compatible,
            allow_codesearch=not disable_codesearch,
            allow_read=not disable_read,
        )
        if self._strict_opencode_isolation_enabled():
            env["OPENCODE_DISABLE_PROJECT_CONFIG"] = "1"
            env["OPENCODE_DISABLE_DEFAULT_PLUGINS"] = "1"
            env["OPENCODE_DISABLE_MODELS_FETCH"] = "1"
        if self.exec_timeout > 0:
            env["OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS"] = str(int(self.exec_timeout * 1000))
        else:
            # OpenCode's bash tool doesn't support true "infinite" timeout via
            # config; use a very large default to emulate no timeout.
            env["OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS"] = str(24 * 60 * 60 * 1000)

        runtime_home = self._opencode_runtime_home()
        runtime_home.mkdir(parents=True, exist_ok=True)
        env["HOME"] = str(runtime_home)
        if os.name == "nt":
            env["USERPROFILE"] = str(runtime_home)

        prompt = self._build_worker_prompt_compact() if compact_prompt else self._build_worker_prompt()
        if disable_codesearch:
            prompt += (
                "\n\nTool availability note:\n"
                "- The codesearch tool is disabled for this run due to a known chunking error.\n"
                "- Use list/glob/grep/read/bash to locate and edit files.\n"
            )
        if disable_read:
            prompt += (
                "- The read tool is disabled for this run.\n"
                "- Use bash with `sed`, `head`, `tail`, `awk`, or `rg` for focused reads.\n"
            )

        cmd: list[str] = [
            *command_prefix,
            "run",
            "--format",
            "json",
            "--model",
            model_selector,
            prompt,
        ]

        logger.info(
            f"Worker [{self.task.id}] starting OpenCode engine | provider={provider_name} | "
            f"model={model_id} | codesearch={'off' if disable_codesearch else 'on'} | "
            f"read={'off' if disable_read else 'on'} | "
            f"prompt={'compact' if compact_prompt else 'full'}"
        )
        boot_action = "starting OpenCode engine"
        self.registry.update_progress(self.task.id, current_action=boot_action)
        if self.narrator:
            self.narrator.narrate(self.task.id, boot_action)

        last_agent_message = ""
        event_errors: list[str] = []
        iteration = 0
        last_activity = [time.monotonic()]

        process = await asyncio.create_subprocess_exec(
            *cmd,
            cwd=str(self.workspace),
            env=env,
            stdout=asyncio.subprocess.PIPE,
            stderr=asyncio.subprocess.PIPE,
        )

        async def _consume_stdout() -> None:
            nonlocal last_agent_message, iteration
            assert process.stdout is not None
            while True:
                raw = await process.stdout.readline()
                if not raw:
                    break
                line = raw.decode("utf-8", errors="replace").strip()
                if not line:
                    continue
                try:
                    evt = json.loads(line)
                except json.JSONDecodeError:
                    continue

                etype = str(evt.get("type", "")).strip()
                if etype == "step_start":
                    iteration += 1
                    last_activity[0] = time.monotonic()
                    self.registry.update_progress(self.task.id, iteration=iteration)
                    continue

                if etype == "tool_use":
                    part = evt.get("part") or {}
                    if not isinstance(part, dict):
                        continue
                    state = part.get("state") or {}
                    args = state.get("input") if isinstance(state, dict) else {}
                    if not isinstance(args, dict):
                        args = {}
                    tool_name = str(part.get("tool", "")).strip()
                    action = self._format_opencode_action(tool_name, args)
                    last_activity[0] = time.monotonic()
                    if self.narrator:
                        self.narrator.narrate(self.task.id, action)
                    self.registry.update_progress(self.task.id, current_action=action)
                    self.registry.update_progress(self.task.id, current_action="", action_completed=action)

                    if tool_name == "bash" and isinstance(state, dict):
                        output = str(state.get("output") or "").strip()
                        if "Exit code:" in output and "Exit code: 0" not in output:
                            preview = output[:280] + ("…" if len(output) > 280 else "")
                            if preview:
                                event_errors.append(preview)
                    continue

                if etype == "text":
                    part = evt.get("part") or {}
                    if isinstance(part, dict):
                        text = str(part.get("text") or "").strip()
                        if text:
                            last_agent_message = text
                            last_activity[0] = time.monotonic()
                    continue

                if etype == "error":
                    msg = self._extract_opencode_error(evt.get("error"))
                    if msg:
                        event_errors.append(msg)
                        last_activity[0] = time.monotonic()
                    continue

        async def _consume_stderr() -> str:
            assert process.stderr is not None
            chunks: list[str] = []
            while True:
                raw = await process.stderr.readline()
                if not raw:
                    break
                txt = raw.decode("utf-8", errors="replace")
                if txt:
                    chunks.append(txt)
            return "".join(chunks)

        stderr_task = asyncio.create_task(_consume_stderr())
        stdout_task = asyncio.create_task(_consume_stdout())
        heartbeat_interval = 20.0
        raw_heartbeat = (os.getenv("KYBER_OPENCODE_HEARTBEAT_SECONDS", "") or "").strip()
        if raw_heartbeat:
            try:
                heartbeat_interval = max(5.0, float(raw_heartbeat))
            except ValueError:
                heartbeat_interval = 20.0

        async def _heartbeat() -> None:
            if heartbeat_interval <= 0:
                return
            while process.returncode is None:
                await asyncio.sleep(min(5.0, heartbeat_interval))
                if process.returncode is not None:
                    break
                idle_for = time.monotonic() - last_activity[0]
                if idle_for < heartbeat_interval:
                    continue
                action = "still working…"
                self.registry.update_progress(self.task.id, current_action=action)
                if self.narrator:
                    self.narrator.narrate(self.task.id, action)
                last_activity[0] = time.monotonic()

        heartbeat_task = asyncio.create_task(_heartbeat())

        try:
            await stdout_task
            rc = await process.wait()
            stderr_output = await stderr_task
        except asyncio.CancelledError:
            if process.returncode is None:
                process.kill()
                with contextlib.suppress(Exception):
                    await process.wait()
            raise
        finally:
            heartbeat_task.cancel()
            with contextlib.suppress(Exception):
                await heartbeat_task

        if rc != 0:
            stderr_preview = (stderr_output or "").strip()
            if len(stderr_preview) > 600:
                stderr_preview = stderr_preview[:600] + "…"
            err_chunks = [e for e in event_errors if e]
            if stderr_preview:
                err_chunks.append(stderr_preview)
            if not err_chunks:
                err_chunks.append(f"OpenCode exited with status {rc}")
            raise RuntimeError(" | ".join(err_chunks[:3]))

        if not last_agent_message:
            if event_errors:
                raise RuntimeError(event_errors[-1])
            raise RuntimeError("OpenCode completed without a final message.")

        return last_agent_message


class WorkerPool:
    """
    Manages concurrent worker execution.

    Spawns workers as async tasks, tracks them, and ensures
    completion notifications are delivered.
    """

    def __init__(
        self,
        provider: LLMProvider,
        workspace: Path,
        registry: TaskRegistry,
        persona_prompt: str,
        model: str | None = None,
        brave_api_key: str | None = None,
        max_concurrent: int = 5,
        timezone: str | None = None,
        exec_timeout: int = 60,
        narrator: LiveNarrator | None = None,
    ):
        self.provider = provider
        self.workspace = workspace
        self.registry = registry
        self.persona_prompt = persona_prompt
        self.model = model
        self.brave_api_key = brave_api_key
        self.max_concurrent = max_concurrent
        self.timezone = timezone
        self.exec_timeout = exec_timeout
        self.narrator = narrator

        # Shared workspace index — built once, reused across all workers
        self.workspace_index = WorkspaceIndex(workspace)

        self.completion_queue: asyncio.Queue[Task] = asyncio.Queue()
        self._running: dict[str, asyncio.Task] = {}

    def spawn(self, task: Task) -> None:
        """Spawn a worker for the task."""
        # Register with narrator for live updates
        if self.narrator:
            self.narrator.register_task(
                task.id, task.origin_channel, task.origin_chat_id, task.label,
            )

        worker = Worker(
            task=task,
            provider=self.provider,
            workspace=self.workspace,
            registry=self.registry,
            completion_queue=self.completion_queue,
            persona_prompt=self.persona_prompt,
            model=self.model,
            brave_api_key=self.brave_api_key,
            exec_timeout=self.exec_timeout,
            timezone=self.timezone,
            workspace_index=self.workspace_index,
            narrator=self.narrator,
        )

        async_task = asyncio.create_task(worker.run())
        self._running[task.id] = async_task

        # Cleanup when done
        def _on_done(_: asyncio.Task) -> None:
            self._running.pop(task.id, None)
            if self.narrator:
                self.narrator.unregister_task(task.id)

        async_task.add_done_callback(_on_done)
        logger.info(f"Spawned worker for task: {task.label} [{task.id}]")

    def cancel(self, task_id: str) -> bool:
        """Cancel a running task by task id."""
        t = self._running.get(task_id)
        if not t:
            return False
        t.cancel()
        return True

    async def run_inline(self, task: Task) -> Task:
        """
        Run a task to completion in the current event loop.

        This is used for "direct"/one-shot invocations where background tasks
        would be cancelled when the process exits (e.g., `kyber agent -m ...`).
        """
        # Use a throwaway queue; Worker.run() always puts the task on completion.
        q: asyncio.Queue[Task] = asyncio.Queue()
        worker = Worker(
            task=task,
            provider=self.provider,
            workspace=self.workspace,
            registry=self.registry,
            completion_queue=q,
            persona_prompt=self.persona_prompt,
            model=self.model,
            brave_api_key=self.brave_api_key,
            exec_timeout=self.exec_timeout,
            timezone=self.timezone,
            workspace_index=self.workspace_index,
            narrator=self.narrator,
        )
        if self.narrator:
            self.narrator.register_task(task.id, task.origin_channel, task.origin_chat_id, task.label)
        try:
            await worker.run()
        finally:
            if self.narrator:
                self.narrator.unregister_task(task.id)
        return task

    @property
    def active_count(self) -> int:
        """Number of currently running workers."""
        return len(self._running)

    async def get_completion(self) -> Task:
        """Wait for and return the next completed task."""
        return await self.completion_queue.get()

    def get_completion_nowait(self) -> Task | None:
        """Get a completed task if available, else None."""
        try:
            return self.completion_queue.get_nowait()
        except asyncio.QueueEmpty:
            return None
