# Copyright (c) Metis. All rights reserved.

"""Python BullMQ worker that consumes platform-verify, platform-dry-run,
and platform-run queues from the same Redis instance as the TS worker.

Entry point: ``mantis-worker`` CLI command.

Usage::

    mantis-worker --redis-host localhost --redis-port 6379 --redis-auth myredissecret

Or via environment variables::

    REDIS_HOST=localhost REDIS_PORT=6379 REDIS_AUTH=myredissecret mantis-worker
"""

from __future__ import annotations

import asyncio
import contextlib
import json
import logging
import os
import platform as _platform
import signal
import sys
import time as _time
from typing import Any, Dict, List, Optional, Tuple

logger = logging.getLogger(__name__)

# Queue names must match QueueName enum in insight/packages/shared/src/server/queues.ts
PLATFORM_VERIFY_QUEUE = "platform-verify-queue"
PLATFORM_DRY_RUN_QUEUE = "platform-dry-run-queue"
PLATFORM_RUN_QUEUE = "platform-run-queue"


# Standardized response helpers (F10: consistent error shapes)
def _error_response(
    errors: list[str],
    *,
    run_id: str | None = None,
    details: dict | None = None,
) -> Dict[str, Any]:
    resp: Dict[str, Any] = {"success": False, "errors": errors}
    if run_id:
        resp["runId"] = run_id
    if details:
        resp["details"] = details
    return resp


def _success_response(data: Dict[str, Any]) -> Dict[str, Any]:
    return {"success": True, **data}


def _build_redis_url(redis_opts: Dict[str, Any]) -> str:
    """Construct ``redis://`` URL from BullMQ connection opts."""
    host = redis_opts.get("host", "localhost")
    port = redis_opts.get("port", 6379)
    password = redis_opts.get("password", "")
    if password:
        return f"redis://:{password}@{host}:{port}"
    return f"redis://{host}:{port}"


def _resolve_algorithm(algorithm_name: str):
    """Resolve algorithm class by name. Returns (algo_cls, error_response)."""
    from mantisdk.platform.registry import get_algorithm_by_name

    algo_cls = get_algorithm_by_name(algorithm_name)
    if algo_cls is None:
        return None, _error_response([f"Unknown algorithm: {algorithm_name}"])
    return algo_cls, None


async def process_verify(job: Any, token: Optional[str] = None) -> Dict[str, Any]:
    """Process PlatformVerifyJob."""
    start = _time.monotonic()
    payload = job.data.get("payload", {})
    algorithm_name = payload.get("algorithmName", "")
    config = payload.get("config", {})

    logger.info(f"Processing verify job: algorithm={algorithm_name}")

    try:
        algo_cls, err = _resolve_algorithm(algorithm_name)
        if err:
            return err

        algo = algo_cls()
        result = algo.verify(config)

        elapsed = _time.monotonic() - start
        logger.info(f"Verify completed for {algorithm_name} in {elapsed:.2f}s: {result}")
        return _success_response(result)
    except Exception as e:
        elapsed = _time.monotonic() - start
        logger.error(f"Verify failed for {algorithm_name} after {elapsed:.2f}s: {e}")
        return _error_response([str(e)])


async def process_dry_run(job: Any, token: Optional[str] = None) -> Dict[str, Any]:
    """Process PlatformDryRunJob."""
    start = _time.monotonic()
    payload = job.data.get("payload", {})
    algorithm_name = payload.get("algorithmName", "")
    num_rollouts = payload.get("numRollouts", 3)

    logger.info(f"Processing dry-run job: algorithm={algorithm_name}, " f"numRollouts={num_rollouts}")

    try:
        algo_cls, err = _resolve_algorithm(algorithm_name)
        if err:
            return err

        algo = algo_cls()
        verify_result = algo.verify(payload.get("config", {}))
        if not verify_result.get("valid", False):
            return _error_response(
                ["Config verification failed"],
                details=verify_result,
            )

        elapsed = _time.monotonic() - start
        logger.info(f"Dry-run completed for {algorithm_name} in {elapsed:.2f}s")
        return _success_response(
            {
                "algorithmName": algorithm_name,
                "rollouts_requested": num_rollouts,
                "verified": True,
            }
        )
    except Exception as e:
        elapsed = _time.monotonic() - start
        logger.error(f"Dry-run failed for {algorithm_name} after {elapsed:.2f}s: {e}")
        return _error_response([str(e)])


async def _finish_run(
    insight_url: str,
    api_key: str,
    secret_key: str,
    run_id: str,
    project_id: str,
    state: str,
) -> None:
    """Notify Insight that a run has changed state (best-effort)."""
    try:
        import httpx
    except ImportError:
        return
    url = f"{insight_url.rstrip('/')}/api/public/runs/{run_id}/finish"
    try:
        async with httpx.AsyncClient(timeout=10.0) as client:
            await client.post(
                url,
                auth=(api_key, secret_key),
                json={"projectId": project_id, "state": state},
            )
    except Exception as exc:
        logger.warning(f"Failed to update run {run_id} to state={state}: {exc}")


# Module-level Redis URL, set during PlatformWorker.start().
_worker_redis_url: Optional[str] = None


class RunCancelled(Exception):
    """Raised when a platform run is cancelled via the API."""


async def _watch_cancellation(redis_url: str, run_id: str) -> None:
    """Block until cancellation signal appears in Redis."""
    if not redis_url:
        logger.warning(f"No Redis URL for cancellation watch (run {run_id}). " "Cancel from UI will not work.")
        # Block forever so asyncio.wait doesn't immediately return
        await asyncio.Event().wait()
        return

    import redis.asyncio as aioredis

    logger.info(f"Cancellation watch started for run {run_id} (redis={redis_url[:30]}...)")
    r = aioredis.from_url(redis_url, decode_responses=True)
    try:
        while True:
            val = await r.get(f"mantis:run:{run_id}:cancel")
            if val:
                logger.info(f"Cancellation signal received for run {run_id}")
                raise RunCancelled(f"Run {run_id} cancelled by user")
            await asyncio.sleep(3)  # Check every 3s instead of 5s for faster response
    except RunCancelled:
        raise
    except Exception as e:
        logger.error(f"Cancellation watch error for run {run_id}: {e}")
        # Don't block forever if Redis connection fails -- let the algo continue
        await asyncio.Event().wait()
    finally:
        await r.aclose()


async def _fetch_and_split_dataset(
    insight_url: str,
    api_key: str,
    secret_key: str,
    dataset_id: str,
    project_id: str,
    val_fraction: float = 0.2,
) -> Tuple[Optional[List], Optional[List]]:
    """Fetch dataset items from Insight API, split into train/val."""
    import random

    try:
        import httpx
    except ImportError:
        logger.warning("httpx not installed, cannot fetch dataset")
        return None, None

    base = insight_url.rstrip("/")
    auth = (api_key, secret_key)

    async with httpx.AsyncClient(timeout=30.0) as client:
        # Step 1: Get dataset name from ID via list endpoint
        datasets_resp = await client.get(f"{base}/api/public/datasets", auth=auth)
        datasets_resp.raise_for_status()
        dataset_name = None
        for ds in datasets_resp.json().get("data", []):
            if ds.get("id") == dataset_id:
                dataset_name = ds["name"]
                break

        if not dataset_name:
            logger.warning(f"Dataset {dataset_id} not found in project")
            return None, None

        # Step 2: Fetch dataset with items via GET /api/public/datasets/{name}
        dataset_resp = await client.get(
            f"{base}/api/public/datasets/{dataset_name}",
            auth=auth,
        )
        dataset_resp.raise_for_status()

    all_items = [item["input"] for item in dataset_resp.json().get("items", []) if item.get("input") is not None]

    if not all_items:
        logger.warning(f"Dataset '{dataset_name}' has no items")
        return None, None

    rng = random.Random(42)
    rng.shuffle(all_items)
    split = max(1, int(len(all_items) * (1 - val_fraction)))
    logger.info(f"Fetched {len(all_items)} items from dataset '{dataset_name}'")
    return all_items[:split], all_items[split:]


def _compute_store_access_host() -> str:
    """Determine the host that agent containers can use to reach the worker."""
    if _platform.system() == "Darwin":
        return "host.docker.internal"
    # On Linux, try to get the default route IP
    try:
        import socket

        s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
        s.connect(("8.8.8.8", 80))
        host = s.getsockname()[0]
        s.close()
        return host
    except Exception:
        return "host.docker.internal"


# ═══════════════════════════════════════════════════════════════════════
# AlgorithmContext — encapsulates all run wiring
# ═══════════════════════════════════════════════════════════════════════


class AlgorithmContext:
    """Async context manager that wires up everything for a platform run.

    Creates:
    - InsightRunListener (attached to existing platform run)
    - InMemoryLightningStore (with listener)
    - LightningStoreServer (HTTP, for agent containers)
    - LLM proxy (with store set)
    - Docker orchestrator + agent container
    - Initial resources (prompt template)
    - Dataset (train/val split)

    On exit, stops server, listener, and containers.
    """

    def __init__(
        self,
        *,
        run_id: str,
        algorithm_name: str,
        config: Dict[str, Any],
        project_id: str,
        agent_image: str,
        agent_manifest: Dict[str, Any],
        agent_resources: Dict[str, Any],
        dataset_id: Optional[str],
        insight_url: str,
        api_key: str,
        secret_key: str,
        secrets: Optional[Dict[str, str]] = None,
        agent_overrides: Optional[Dict[str, Any]] = None,
    ):
        self.run_id = run_id
        self.algorithm_name = algorithm_name
        self.config = config
        self.project_id = project_id
        self.agent_image = agent_image
        self.agent_manifest = agent_manifest
        self.agent_resources = agent_resources
        self.dataset_id = dataset_id
        self.insight_url = insight_url
        self.api_key = api_key
        self.secret_key = secret_key
        self.secrets = secrets or {}
        self.agent_overrides = agent_overrides or {}

        # Set during __aenter__
        self.algo = None
        self.store = None
        self.store_server = None
        self.store_url = None
        self.listener = None
        self.orch = None
        self.handle = None  # ComputeHandle from orchestrator
        self.train_dataset = None
        self.val_dataset = None

    async def __aenter__(self) -> "AlgorithmContext":
        # --- 1. Resolve, verify, and construct algorithm with config ---
        algo_cls, err = _resolve_algorithm(self.algorithm_name)
        if err:
            raise RuntimeError(err["errors"][0])

        # Verify config first (using a throwaway instance for schema validation)
        verify_instance = algo_cls()
        verify_result = verify_instance.verify(self.config)
        if not verify_result.get("valid", False):
            raise RuntimeError(f"Algorithm verification failed: {verify_result}")

        # Merge smart_defaults (schema defaults for missing keys) with user config,
        # then pass matching kwargs to the real constructor so UI-configured values
        # (parallelism, model, etc.) actually reach the algorithm instance.
        import inspect

        merged_config = {**verify_result.get("smart_defaults", {}), **self.config}
        sig = inspect.signature(algo_cls.__init__)
        valid_params = {k for k in sig.parameters if k != "self"}
        ctor_kwargs = {k: v for k, v in merged_config.items() if k in valid_params}
        self.algo = algo_cls(**ctor_kwargs)
        logger.info(f"Algorithm {self.algorithm_name} constructed with config: {ctor_kwargs}")

        # --- 2. Create store via InsightLightningStore (reuses platform run) ---
        from mantisdk.store.insight import InsightLightningStore

        self.store = InsightLightningStore(
            api_key=self.api_key,
            secret_key=self.secret_key,
            insight_url=self.insight_url,
            project_id=self.project_id,
            run_id=self.run_id,  # Reuse the platform run, no duplicate
            source="platform",
            thread_safe=True,
        )
        self.listener = self.store._insight_listener  # type: ignore[attr-defined]
        self.algo.set_store(self.store)

        # Ensure _current_run is set in this async context so
        # TracingContextSpanProcessor injects insight.run.id into all spans.
        # run_in_executor (Python 3.12+) copies ContextVars to the executor thread.
        from mantisdk.run import _set_current_run

        _set_current_run(self.store._run)  # type: ignore[attr-defined]

        logger.info(f"InsightLightningStore attached to platform run {self.run_id}")

        # --- 4. Start LightningStoreServer (HTTP for agent containers) ---
        from mantisdk.store.client_server import LightningStoreServer

        # Pick a free port explicitly (port=0 breaks health check probing)
        try:
            from portpicker import pick_unused_port

            server_port = pick_unused_port()
        except ImportError:
            import socket

            with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
                s.bind(("", 0))
                server_port = s.getsockname()[1]

        self.store_server = LightningStoreServer(
            self.store,
            host="0.0.0.0",
            port=server_port,
            launch_mode="thread",
        )
        await self.store_server.start()

        access_host = _compute_store_access_host()
        self.store_url = f"http://{access_host}:{server_port}"
        logger.info(f"StoreServer started at {self.store_url}")

        # --- 5. Configure LLM proxy (reflection model for algorithms like GEPA) ---
        model = self.config.get("reflection_model", self.config.get("model", "anthropic/claude-haiku-4-5"))
        try:
            from mantisdk.llm_proxy import LLMProxy

            # Clear DATABASE_URL so LiteLLM doesn't try to init its own Prisma DB
            os.environ.pop("DATABASE_URL", None)

            # The proxy's model_name is what clients see (via as_resource()).
            # The litellm_params.model is what LiteLLM uses for provider routing.
            # Use the BARE name as model_name so callers don't get the provider
            # prefix -- otherwise litellm.completion() with api_base will route
            # to the Anthropic handler which strips the prefix, and the proxy
            # won't recognize the bare name.
            bare_model = model.split("/", 1)[-1] if "/" in model else model
            model_list = [
                {"model_name": bare_model, "litellm_params": {"model": model}},
            ]

            # Get OTLP endpoint from InsightRunListener for trace export
            otlp_endpoint = self.listener.otlp_traces_endpoint() if self.listener else None
            otlp_headers = self.listener.get_otlp_headers() if self.listener else None

            # Keep strong reference on self — Algorithm stores a weakref
            self._llm_proxy = LLMProxy(
                model_list=model_list,
                callbacks=["opentelemetry"],
                launch_mode="thread",
                otlp_endpoint=otlp_endpoint,
                otlp_headers=otlp_headers,
            )
            self._llm_proxy.set_store(self.store)
            self.algo.set_llm_proxy(self._llm_proxy)
            logger.info(f"Configured LLM proxy: client-facing={bare_model}, provider={model}")
            if otlp_endpoint:
                logger.info(f"OTLP trace export configured: {otlp_endpoint}")
        except Exception as exc:
            logger.warning(f"Failed to configure LLM proxy: {exc}")

        # --- 6. Start agent container ---

        # Apply per-run agent overrides (Phase 2: UI-configurable overrides)
        if self.agent_overrides:
            override_runner = self.agent_overrides.get("runner", {})
            if override_runner:
                manifest_runner = self.agent_manifest.get("runner", {})
                manifest_runner.update(override_runner)
                self.agent_manifest["runner"] = manifest_runner

            override_resources = self.agent_overrides.get("resources", {})
            if override_resources:
                manifest_rl = self.agent_manifest.get(
                    "resource_limits",
                    self.agent_resources.get("resource_limits", {}),
                )
                manifest_rl.update(override_resources)
                self.agent_manifest["resource_limits"] = manifest_rl

        runner_config = self.agent_manifest.get("runner", {})
        resource_limits = self.agent_manifest.get("resource_limits", self.agent_resources.get("resource_limits", {}))
        # Secrets from ProjectSecret take precedence, manifest env can override
        agent_env = {**self.secrets, **self.agent_manifest.get("env", {})}

        if not self.algo.__class__.manages_containers:
            from mantisdk.platform.orchestrator import (
                ContainerLifecycle,
                DockerOrchestrator,
            )

            self.orch = DockerOrchestrator()
            if not self.orch.available:
                raise RuntimeError("Docker not available for container management")

            lifecycle = ContainerLifecycle(runner_config.get("container_lifecycle", "persistent"))

            # Start with zero containers.  Passing store= enables
            # reactive scaling: the orchestrator starts containers
            # on demand as rollouts are enqueued, and replaces them
            # after completion when lifecycle is ephemeral.
            self.handle = self.orch.start(
                run_id=self.run_id,
                image=self.agent_image,
                store_url=self.store_url,
                count=0,
                lifecycle=lifecycle,
                env=agent_env,
                runner_config=runner_config,
                resource_limits=resource_limits,
                store=self.store,
                n_runners_per_unit=runner_config.get("n_runners", 1),
                max_units=self.config.get("max_containers", 20),
            )
            logger.info(
                f"Orchestrator ready for run {self.run_id} "
                f"(lifecycle={lifecycle.value}, containers will start on demand)"
            )

        # --- 7. Fetch dataset ---
        if self.dataset_id:
            try:
                val_fraction = self.config.get("val_fraction", 0.2)
                self.train_dataset, self.val_dataset = await _fetch_and_split_dataset(
                    self.insight_url,
                    self.api_key,
                    self.secret_key,
                    self.dataset_id,
                    self.project_id,
                    val_fraction=val_fraction,
                )
                if self.train_dataset:
                    logger.info(
                        f"Dataset {self.dataset_id}: {len(self.train_dataset)} train, "
                        f"{len(self.val_dataset or [])} val"
                    )
            except Exception as exc:
                logger.warning(f"Failed to fetch dataset {self.dataset_id}: {exc}")

        # --- 8. Set initial resources (prompt template for GEPA) ---
        from mantisdk.types import PromptTemplate

        initial_prompt = self.config.get(
            "initial_prompt",
            "You are a helpful assistant. Complete the task given to you.\n\n{input}",
        )
        self.algo.set_initial_resources(
            {
                "system_prompt": PromptTemplate(template=initial_prompt, engine="f-string"),
            }
        )
        logger.info(f"Set initial resources with prompt ({len(initial_prompt)} chars)")

        return self

    async def __aexit__(self, exc_type, exc_val, exc_tb):
        # Stop containers via handle (preferred) or label fallback
        # stop() also tears down the reactive listener automatically.
        if self.orch:
            try:
                if self.handle:
                    self.orch.stop(self.handle)
                elif self.run_id:
                    # Fallback: stop by label if handle wasn't created
                    self.orch.stop_all_for_run(self.run_id)
            except Exception as exc:
                logger.warning(f"Error stopping containers: {exc}")

        # Stop store server
        if self.store_server:
            try:
                await self.store_server.stop()
                logger.info("StoreServer stopped")
            except Exception as exc:
                logger.warning(f"Error stopping store server: {exc}")

        # Finish run via store (delegates to InsightRunListener)
        if self.store:
            try:
                if exc_type is RunCancelled:
                    self.store.fail("cancelled")  # type: ignore[attr-defined]
                elif exc_type is not None:
                    self.store.fail(str(exc_val) if exc_val else None)  # type: ignore[attr-defined]
                # Success case: caller marks complete explicitly via store.complete()
            except Exception as exc:
                logger.warning(f"Error finishing run: {exc}")

        return False  # Don't suppress exceptions


# ═══════════════════════════════════════════════════════════════════════
# process_run — simplified via AlgorithmContext
# ═══════════════════════════════════════════════════════════════════════


async def process_run(job: Any, token: Optional[str] = None) -> Dict[str, Any]:
    """Process PlatformRunJob — the core execution engine.

    Uses AlgorithmContext to wire up:
    - InMemoryLightningStore + InsightRunListener (single source of truth)
    - LightningStoreServer (HTTP, for agent containers)
    - LLM proxy, dataset, initial resources, Docker containers
    """
    start = _time.monotonic()
    payload = job.data.get("payload", {})
    run_id = payload.get("runId", "")
    algorithm_name = payload.get("algorithmName", "")
    config = payload.get("config", {})
    project_id = payload.get("projectId", "")

    logger.info(f"Processing run job: run={run_id}, algorithm={algorithm_name}")

    # Read Insight credentials from worker env
    insight_url = os.environ.get("INSIGHT_HOST", "http://localhost:3000")
    api_key = os.environ.get("INSIGHT_PUBLIC_KEY", "")
    secret_key = os.environ.get("INSIGHT_SECRET_KEY", "")
    insight_project_id = os.environ.get("INSIGHT_PROJECT_ID", project_id)

    # Transition the run from "pending" to "running"
    await _finish_run(insight_url, api_key, secret_key, run_id, insight_project_id, "running")

    redis_url = _worker_redis_url or ""
    if redis_url:
        logger.info(f"Redis URL for cancellation: {redis_url[:30]}...")
    else:
        logger.warning("No Redis URL configured -- run cancellation will not work")

    try:
        async with AlgorithmContext(
            run_id=run_id,
            algorithm_name=algorithm_name,
            config=config,
            project_id=insight_project_id,
            agent_image=payload.get("agentImage", ""),
            agent_manifest=payload.get("agentManifest", {}),
            agent_resources=payload.get("agentResources", {}),
            dataset_id=payload.get("datasetId"),
            insight_url=insight_url,
            api_key=api_key,
            secret_key=secret_key,
            secrets=payload.get("secrets", {}),
            agent_overrides=payload.get("agentOverrides", {}),
        ) as ctx:
            # Execute algorithm (with cancellation watch)
            logger.info(f"Executing algorithm {algorithm_name} for run {run_id}")
            algo_coro = (
                ctx.algo.run(train_dataset=ctx.train_dataset, val_dataset=ctx.val_dataset)
                if ctx.algo.is_async()
                else asyncio.to_thread(ctx.algo.run, train_dataset=ctx.train_dataset, val_dataset=ctx.val_dataset)
            )
            algo_task = asyncio.create_task(algo_coro)
            cancel_task = asyncio.create_task(_watch_cancellation(redis_url, run_id))

            done, pending = await asyncio.wait(
                {algo_task, cancel_task},
                return_when=asyncio.FIRST_COMPLETED,
            )

            # Check if cancellation won the race
            _cancelled = False
            for t in done:
                try:
                    t.result()
                except RunCancelled:
                    _cancelled = True

            if _cancelled:
                # Signal the algorithm to stop gracefully.  For sync algorithms
                # running in a thread (e.g. GEPA via asyncio.to_thread), task.cancel()
                # has no effect -- we need to set the cancel flag so the engine's
                # _should_stop() returns True on the next iteration.
                logger.info(f"Signalling algorithm cancel for run {run_id}")
                ctx.algo.request_cancel()

                # Give the algorithm thread up to 30s to finish its current
                # iteration and exit the optimization loop cleanly.
                if algo_task in pending:
                    try:
                        await asyncio.wait_for(asyncio.shield(algo_task), timeout=30)
                        logger.info(f"Algorithm exited cleanly after cancel for run {run_id}")
                    except asyncio.TimeoutError:
                        logger.warning(f"Algorithm did not exit within 30s for run {run_id}, forcing cancel")
                        algo_task.cancel()
                        with contextlib.suppress(asyncio.CancelledError):
                            await algo_task
                    except Exception:
                        # Algorithm raised an error while shutting down -- acceptable
                        pass

                # Cancel the remaining watch task
                if cancel_task in pending:
                    cancel_task.cancel()
                    with contextlib.suppress(asyncio.CancelledError):
                        await cancel_task

                raise RunCancelled(f"Run {run_id} cancelled by user")

            # No cancellation -- cancel the watch task and propagate algo errors
            for t in pending:
                t.cancel()
                with contextlib.suppress(asyncio.CancelledError):
                    await t

            for t in done:
                t.result()

            # Success
            elapsed = _time.monotonic() - start
            logger.info(f"Run {run_id} completed in {elapsed:.2f}s")
            ctx.store.complete()  # type: ignore[attr-defined]
            await _finish_run(insight_url, api_key, secret_key, run_id, insight_project_id, "completed")
            return _success_response({"runId": run_id, "algorithmName": algorithm_name})

    except RunCancelled:
        elapsed = _time.monotonic() - start
        logger.info(f"Run {run_id} cancelled after {elapsed:.2f}s")
        await _finish_run(insight_url, api_key, secret_key, run_id, insight_project_id, "cancelled")
        return _success_response({"runId": run_id, "cancelled": True})

    except Exception as e:
        elapsed = _time.monotonic() - start
        logger.error(f"Run {run_id} failed after {elapsed:.2f}s: {e}")
        await _finish_run(insight_url, api_key, secret_key, run_id, insight_project_id, "failed")
        return _error_response([str(e)], run_id=run_id)


# ═══════════════════════════════════════════════════════════════════════
# PlatformWorker — manages BullMQ workers
# ═══════════════════════════════════════════════════════════════════════


class PlatformWorker:
    """Manages three BullMQ Workers, one per platform queue."""

    def __init__(self, redis_opts: Dict[str, Any]):
        self._redis_opts = redis_opts
        self._workers: list = []
        self._shutdown_event = asyncio.Event()

    async def start(self) -> None:
        """Start all three workers."""
        try:
            from bullmq import Worker
        except ImportError:
            logger.error("bullmq package is required. Install with: pip install bullmq>=2.19.4")
            return

        from mantisdk.platform.registry import discover_algorithms

        registry = discover_algorithms()
        logger.info(f"Algorithm registry: {list(registry.keys())}")

        global _worker_redis_url
        _worker_redis_url = _build_redis_url(self._redis_opts)

        self._workers = [
            Worker(
                PLATFORM_VERIFY_QUEUE,
                process_verify,
                {"connection": self._redis_opts, "concurrency": 5},
            ),
            Worker(
                PLATFORM_DRY_RUN_QUEUE,
                process_dry_run,
                {"connection": self._redis_opts, "concurrency": 2},
            ),
            Worker(
                PLATFORM_RUN_QUEUE,
                process_run,
                {"connection": self._redis_opts, "concurrency": 1},
            ),
        ]

        host = self._redis_opts.get("host", "localhost")
        port = self._redis_opts.get("port", 6379)
        logger.info(f"Platform worker started, consuming 3 queues from {host}:{port}")

        await self._shutdown_event.wait()

    async def stop(self) -> None:
        """Gracefully close all workers."""
        for w in self._workers:
            try:
                await w.close()
            except Exception as e:
                logger.warning(f"Error closing worker: {e}")
        self._shutdown_event.set()
        logger.info("Platform worker stopped")

    def request_shutdown(self) -> None:
        """Signal shutdown from signal handler."""
        self._shutdown_event.set()


def _parse_redis_url(url: str) -> Dict[str, Any]:
    """Parse redis://[:password@]host:port[/db] into BullMQ connection opts."""
    from urllib.parse import urlparse

    parsed = urlparse(url)
    opts: Dict[str, Any] = {
        "host": parsed.hostname or "localhost",
        "port": parsed.port or 6379,
    }
    if parsed.password:
        opts["password"] = parsed.password
    db = parsed.path.lstrip("/")
    if db:
        opts["db"] = int(db)
    return opts


def main() -> None:
    """CLI entry point for ``mantis-worker``."""
    try:
        import typer
    except ImportError:
        print(
            "typer is required for mantis-worker. " "Install with: pip install mantisdk[worker]",
            file=sys.stderr,
        )
        sys.exit(1)

    logging.basicConfig(
        level=logging.INFO,
        format="%(asctime)s %(levelname)-8s [%(name)s] %(message)s",
    )

    app = typer.Typer(
        name="mantis-worker",
        help="Mantis platform Python worker — consumes platform queues.",
    )

    @app.command()
    def run(
        redis_url: Optional[str] = typer.Option(
            None,
            envvar="REDIS_URL",
            help="Redis URL (e.g., redis://:myredissecret@localhost:6379)",
        ),
        redis_host: str = typer.Option("localhost", envvar="REDIS_HOST"),
        redis_port: int = typer.Option(6379, envvar="REDIS_PORT"),
        redis_auth: str = typer.Option("", envvar="REDIS_AUTH"),
    ) -> None:
        """Start the platform worker."""
        if redis_url:
            redis_opts = _parse_redis_url(redis_url)
        else:
            redis_opts: Dict[str, Any] = {
                "host": redis_host,
                "port": redis_port,
            }
            if redis_auth:
                redis_opts["password"] = redis_auth

        worker = PlatformWorker(redis_opts)

        loop = asyncio.new_event_loop()

        def handle_signal(sig: int, _frame: Any) -> None:
            logger.info(f"Received signal {sig}, shutting down...")
            loop.call_soon_threadsafe(worker.request_shutdown)

        signal.signal(signal.SIGINT, handle_signal)
        signal.signal(signal.SIGTERM, handle_signal)

        try:
            loop.run_until_complete(worker.start())
        finally:
            loop.run_until_complete(worker.stop())
            loop.close()

    app()


if __name__ == "__main__":
    main()
