"""
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 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
    """
    
    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] = []
        for fn in bootstrap_files:
            p = self.workspace / fn
            if not p.exists():
                continue
            try:
                bootstrap_parts.append(f"## {fn}\n\n{p.read_text(encoding='utf-8')}")
            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 _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:
                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)
            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 _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()}")

    def _install_managed_opencode(self) -> Path:
        """Download and install OpenCode binary into Kyber runtime dir."""
        target = self._managed_opencode_path()
        if target.exists():
            return target

        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}")

        url = asset["browser_download_url"]
        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)
            shutil.copy2(source, target)
            if os.name != "nt":
                target.chmod(0o755)

        return target

    async def _resolve_opencode_command_prefix(self) -> list[str]:
        """Resolve how to invoke OpenCode."""
        managed = self._managed_opencode_path()
        if managed.exists():
            return [str(managed)]

        try:
            installed = await asyncio.to_thread(self._install_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,
    ) -> str:
        """Build isolated OpenCode config for Kyber worker execution."""
        provider_options: dict[str, Any] = {
            "timeout": max(30_000, int(self.exec_timeout * 1000)),
        }
        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",
                "edit": "allow",
                "list": "allow",
                "glob": "allow",
                "grep": "allow",
                "bash": "allow",
                "webfetch": "allow",
                "websearch": "allow",
                "codesearch": "allow",
                "external_directory": "allow",
                "todowrite": "allow",
                "todoread": "allow",
                "question": "deny",
                "task": "deny",
            },
            "provider": {
                provider_id: provider_cfg,
            },
        }
        return json.dumps(config, separators=(",", ":"))

    @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() + "…"

    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":
            u = self._shorten(str(args.get("url", "")))
            return f"opening {u}" if u 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 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,
        )
        env["OPENCODE_DISABLE_PROJECT_CONFIG"] = "1"
        env["OPENCODE_DISABLE_DEFAULT_PLUGINS"] = "1"
        env["OPENCODE_DISABLE_MODELS_FETCH"] = "1"

        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)

        cmd: list[str] = [
            *command_prefix,
            "run",
            "--format",
            "json",
            "--model",
            model_selector,
            self._build_worker_prompt(),
        ]

        logger.info(
            f"Worker [{self.task.id}] starting OpenCode engine | provider={provider_name} | model={model_id}"
        )

        last_agent_message = ""
        event_errors: list[str] = []
        iteration = 0

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

                if etype == "error":
                    msg = self._extract_opencode_error(evt.get("error"))
                    if msg:
                        event_errors.append(msg)
                    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())

        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

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

        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,
        )
        await worker.run()
        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
