"""Pipeline Worker -- auto-execution daemon for PipelineQueue tasks.

Watches the queue and executes claimed tasks via one of three executor backends:

    ollama        -- Local Ollama model with native tool calling (Read/Write/Edit/Bash/Glob/Grep).
                     Fully local, zero API costs, no policy questions.

    api           -- Anthropic API with tool use (Messages API + tool_use blocks).
                     Standard API automation, well-established use case.

    claude-code   -- Claude Agent SDK (pip install claude-agent-sdk).
                     Officially blessed by Anthropic for programmatic automation as of
                     March 2026. Uses claude_agent_sdk.query() with permission_mode
                     "acceptEdits" and allowed tools. Full native tool access --
                     Claude reads files, edits them surgically, runs tests, iterates.
                     Requires: pip install claude-agent-sdk

Usage::

    from pathlib import Path
    from cohort.pipeline import PipelineQueue
    from cohort.pipeline.worker import PipelineWorker

    queue = PipelineQueue(data_dir=Path("data"), repo_dir=Path("."))
    worker = PipelineWorker(queue, executor="ollama", project_root=Path("."))
    worker.start()   # daemon thread, non-blocking
    ...
    worker.stop()

Or run once (process one task then exit)::

    result = worker.run_once()

Settings can be overridden via environment variables or the settings dict:
    COHORT_WORKER_EXECUTOR      -- ollama | api | claude-code
    COHORT_WORKER_MODEL         -- model name / tier
    COHORT_WORKER_MAX_TURNS     -- max tool-call iterations (default 25)
    COHORT_WORKER_TIMEOUT       -- seconds before task is killed (default 600)
    COHORT_WORKER_POLL_INTERVAL -- seconds between queue polls (default 5)
"""

from __future__ import annotations

import logging
import os
import threading
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Dict, List, Optional

from cohort.pipeline.pipeline import PipelineQueue
from cohort.pipeline.pipeline_task import PipelineTask, StalenessLevel

logger = logging.getLogger(__name__)

# Optional imports -- available when cohort[mcp] is installed
try:
    from cohort.local.ollama import OllamaClient
    from cohort.local.tools import build_tool_schemas, execute_tool
except ImportError:  # pragma: no cover
    OllamaClient = None  # type: ignore[assignment,misc]
    build_tool_schemas = None  # type: ignore[assignment]
    execute_tool = None  # type: ignore[assignment]

# anthropic is optional -- only needed for executor='api'
try:
    import anthropic
except ImportError:  # pragma: no cover
    anthropic = None  # type: ignore[assignment]

# claude_agent_sdk is optional -- only needed for executor='claude-code'
try:
    from claude_agent_sdk import ClaudeAgentOptions, query
    _CLAUDE_AGENT_SDK_AVAILABLE = True
except ImportError:  # pragma: no cover
    ClaudeAgentOptions = None  # type: ignore[assignment,misc]
    query = None  # type: ignore[assignment]
    _CLAUDE_AGENT_SDK_AVAILABLE = False

# ---------------------------------------------------------------------------
# Executor constants
# ---------------------------------------------------------------------------

EXECUTOR_OLLAMA = "ollama"
EXECUTOR_API = "api"
EXECUTOR_CLAUDE_CODE = "claude-code"  # stub -- not wired

_VALID_EXECUTORS = {EXECUTOR_OLLAMA, EXECUTOR_API, EXECUTOR_CLAUDE_CODE}

# ---------------------------------------------------------------------------
# Worker settings
# ---------------------------------------------------------------------------

def _default_settings() -> Dict[str, Any]:
    return {
        "executor": os.environ.get("COHORT_WORKER_EXECUTOR", EXECUTOR_OLLAMA),
        "model": os.environ.get("COHORT_WORKER_MODEL", "qwen2.5-coder:7b"),
        "max_turns": int(os.environ.get("COHORT_WORKER_MAX_TURNS", "25")),
        "timeout": int(os.environ.get("COHORT_WORKER_TIMEOUT", "600")),
        "poll_interval": float(os.environ.get("COHORT_WORKER_POLL_INTERVAL", "5")),
    }


DEFAULT_SETTINGS: Dict[str, Any] = _default_settings()

# Dangerous commands never allowed in Bash tool
_BASH_BLOCKLIST = [
    "rm -rf", "rm -r /", "rmdir /s",
    "git push", "git push --force",
    "format ", "del /f", "rd /s",
    "shutdown", "restart",
    "DROP TABLE", "DROP DATABASE",
    "curl | bash", "wget | bash",
]

# ---------------------------------------------------------------------------
# WorkerStatus
# ---------------------------------------------------------------------------

@dataclass
class WorkerStatus:
    state: str = "idle"          # idle | running | completed | error
    task_id: Optional[str] = None
    started_at: Optional[str] = None
    completed_at: Optional[str] = None
    last_error: Optional[str] = None
    tasks_completed: int = 0
    executor: Optional[str] = None


# ---------------------------------------------------------------------------
# PipelineWorker
# ---------------------------------------------------------------------------

class PipelineWorker:
    """Auto-execution daemon for PipelineQueue.

    Parameters
    ----------
    queue:
        The PipelineQueue to watch.
    executor:
        ``"ollama"`` | ``"api"`` | ``"claude-code"`` (stub).
    project_root:
        Root directory for file operations (path safety boundary).
    model:
        Model name or tier. For ollama: Ollama model tag. For api: Claude model ID.
    max_turns:
        Maximum tool-call iterations per task.
    timeout:
        Seconds before a running task is killed.
    poll_interval:
        Seconds between queue polls when idle.
    settings:
        Override any default setting by key.
    """

    def __init__(
        self,
        queue: PipelineQueue,
        executor: str = EXECUTOR_OLLAMA,
        project_root: Optional[Path] = None,
        model: Optional[str] = None,
        max_turns: Optional[int] = None,
        timeout: Optional[int] = None,
        poll_interval: Optional[float] = None,
        settings: Optional[Dict[str, Any]] = None,
    ) -> None:
        if executor not in _VALID_EXECUTORS:
            raise ValueError(f"executor must be one of {_VALID_EXECUTORS}, got {executor!r}")

        self._queue = queue
        self._project_root = project_root or Path(".")

        # Merge settings
        cfg = _default_settings()
        if settings:
            cfg.update(settings)
        self.executor = executor or cfg["executor"]
        self.model = model or cfg["model"]
        self.max_turns = max_turns if max_turns is not None else cfg["max_turns"]
        self.timeout = timeout if timeout is not None else cfg["timeout"]
        self.poll_interval = poll_interval if poll_interval is not None else cfg["poll_interval"]

        self._status = WorkerStatus(executor=self.executor)
        self._status_lock = threading.Lock()
        self._stop_event = threading.Event()
        self._thread: Optional[threading.Thread] = None

    # ------------------------------------------------------------------
    # Public control API
    # ------------------------------------------------------------------

    def start(self) -> None:
        """Start the daemon thread. Non-blocking."""
        if self._thread and self._thread.is_alive():
            logger.warning("[worker] Already running")
            return
        self._stop_event.clear()
        self._thread = threading.Thread(
            target=self._loop, name="cohort-pipeline-worker", daemon=True
        )
        self._thread.start()
        logger.info("[worker] Started (executor=%s, model=%s)", self.executor, self.model)

    def stop(self, timeout: float = 10.0) -> None:
        """Signal the daemon to stop and wait for it."""
        self._stop_event.set()
        if self._thread:
            self._thread.join(timeout=timeout)
        logger.info("[worker] Stopped")

    def run_once(self) -> Optional[Dict[str, Any]]:
        """Process one task from the queue synchronously.

        Returns a result dict, or None if the queue was empty.
        """
        return self._process_next()

    def get_status(self) -> WorkerStatus:
        with self._status_lock:
            import copy
            return copy.copy(self._status)

    # ------------------------------------------------------------------
    # Daemon loop
    # ------------------------------------------------------------------

    def _loop(self) -> None:
        logger.info("[worker] Loop started")
        while not self._stop_event.is_set():
            try:
                result = self._process_next()
                if result is None:
                    # Queue empty -- wait before polling again
                    self._stop_event.wait(self.poll_interval)
            except Exception as exc:
                logger.exception("[worker] Unhandled error in loop: %s", exc)
                self._stop_event.wait(self.poll_interval)
        logger.info("[worker] Loop exited")

    # ------------------------------------------------------------------
    # Task processing
    # ------------------------------------------------------------------

    def _process_next(self) -> Optional[Dict[str, Any]]:
        """Claim and execute the next available task. Returns None if queue empty."""
        task = self._queue.claim_next()
        if task is None:
            return None

        # Staleness bounce -- don't auto-execute uncertain/conflicted tasks
        if task.staleness_level in (StalenessLevel.UNCERTAIN, StalenessLevel.CONFLICTED):
            logger.warning(
                "[worker] Task %s bounced (staleness=%s, escalated_to=%s)",
                task.item.id, task.staleness_level, task.escalated_to,
            )
            # Leave it active -- human/orchestrator needs to decide
            return {"task_id": task.item.id, "outcome": "bounced", "reason": task.staleness_report}

        task_id = task.item.id
        self._set_running(task_id)
        logger.info("[worker] Executing %s via %s", task_id, self.executor)

        try:
            if self.executor == EXECUTOR_OLLAMA:
                outcome = self._execute_ollama(task)
            elif self.executor == EXECUTOR_API:
                outcome = self._execute_api(task)
            elif self.executor == EXECUTOR_CLAUDE_CODE:
                outcome = self._execute_claude_code(task)
            else:
                raise ValueError(f"Unknown executor: {self.executor}")

            self._set_idle(task_id)
            return outcome

        except Exception as exc:
            error = str(exc)
            logger.exception("[worker] Task %s failed: %s", task_id, error)
            self._set_error(task_id, error)
            self._queue.fail(task, reason=error)
            return {"task_id": task_id, "outcome": "error", "reason": error}

    # ------------------------------------------------------------------
    # Executor: Ollama (local LLM with tool calling)
    # ------------------------------------------------------------------

    def _execute_ollama(self, task: PipelineTask) -> Dict[str, Any]:
        """Execute via local Ollama model using native tool calling."""
        if OllamaClient is None or build_tool_schemas is None or execute_tool is None:
            raise RuntimeError(
                "cohort local tools unavailable -- ensure cohort is installed correctly"
            )

        client = OllamaClient()
        tools = build_tool_schemas(["Read", "Glob", "Grep", "Bash", "Write", "Edit"])
        prompt = self._build_brief(task)

        messages: List[Dict[str, Any]] = [{"role": "user", "content": prompt}]
        files_modified: List[str] = []
        turn = 0

        logger.info("[worker/ollama] Starting tool loop (max_turns=%d)", self.max_turns)

        while turn < self.max_turns:
            turn += 1
            response = client.chat(
                model=self.model,
                messages=messages,
                tools=tools,
            )

            # Accumulate any files written/edited from tool calls
            tool_calls = response.get("message", {}).get("tool_calls", [])  # type: ignore[union-attr]
            if not tool_calls:
                # No more tool calls -- model is done
                final_text = response.get("message", {}).get("content", "")  # type: ignore[union-attr]
                logger.info("[worker/ollama] Completed in %d turns", turn)
                break

            # Execute each tool call
            tool_results = []
            for tc in tool_calls:
                name = tc.get("function", {}).get("name", "")
                args = tc.get("function", {}).get("arguments", {})
                result = execute_tool(name, args, self._project_root)
                tool_results.append({"role": "tool", "name": name, "content": result})
                # Track modified files
                if name in ("Write", "Edit") and "file_path" in args:
                    fp = args["file_path"]
                    if fp not in files_modified:
                        files_modified.append(fp)

            messages.append(response.get("message", {}))  # type: ignore[union-attr]
            messages.extend(tool_results)
        else:
            logger.warning("[worker/ollama] Hit max_turns=%d without finishing", self.max_turns)
            final_text = "Max turns reached"

        # Attach self-review and submit
        self._finalize_task(task, files_modified, final_text or "Completed via Ollama")
        return {"task_id": task.item.id, "outcome": "completed", "files_modified": files_modified}

    # ------------------------------------------------------------------
    # Executor: Anthropic API (tool use via Messages API)
    # ------------------------------------------------------------------

    def _execute_api(self, task: PipelineTask) -> Dict[str, Any]:
        """Execute via Anthropic Messages API with tool use blocks."""
        if anthropic is None:
            raise RuntimeError(
                "anthropic package required for API executor: pip install anthropic"
            )
        if execute_tool is None:
            raise RuntimeError("cohort local tools unavailable")

        client = anthropic.Anthropic()
        prompt = self._build_brief(task)

        # Build tool schemas in Anthropic format
        tools = self._anthropic_tool_schemas()
        messages: List[Dict[str, Any]] = [{"role": "user", "content": prompt}]
        files_modified: List[str] = []
        final_text = ""
        turn = 0

        logger.info("[worker/api] Starting tool loop (model=%s, max_turns=%d)",
                    self.model, self.max_turns)

        while turn < self.max_turns:
            turn += 1
            response = client.messages.create(
                model=self.model,
                max_tokens=4096,
                tools=tools,  # type: ignore[arg-type]
                messages=messages,  # type: ignore[arg-type]
            )

            # Collect text and tool_use blocks
            tool_uses = []
            for block in response.content:
                if block.type == "text":
                    final_text = block.text
                elif block.type == "tool_use":
                    tool_uses.append(block)

            if not tool_uses or response.stop_reason == "end_turn":
                logger.info("[worker/api] Completed in %d turns (stop=%s)",
                            turn, response.stop_reason)
                break

            # Append assistant message
            messages.append({"role": "assistant", "content": response.content})  # type: ignore[arg-type]

            # Execute tools and build tool_result blocks
            tool_result_content = []
            for tu in tool_uses:
                result = execute_tool(tu.name, tu.input, self._project_root)
                tool_result_content.append({
                    "type": "tool_result",
                    "tool_use_id": tu.id,
                    "content": result,
                })
                if tu.name in ("Write", "Edit") and "file_path" in tu.input:
                    fp = str(tu.input["file_path"])
                    if fp not in files_modified:
                        files_modified.append(fp)

            messages.append({"role": "user", "content": tool_result_content})
        else:
            logger.warning("[worker/api] Hit max_turns=%d", self.max_turns)

        self._finalize_task(task, files_modified, final_text or "Completed via API")
        return {"task_id": task.item.id, "outcome": "completed", "files_modified": files_modified}

    # ------------------------------------------------------------------
    # Executor: Claude Code CLI (stub -- not wired)
    # ------------------------------------------------------------------

    def _execute_claude_code(self, task: PipelineTask) -> Dict[str, Any]:
        """Execute via the official Claude Agent SDK.

        Uses claude_agent_sdk.query() -- Anthropic's officially supported
        programmatic automation API (released March 2026).

        Requires: pip install claude-agent-sdk

        The SDK handles the full tool loop natively -- Claude reads files,
        edits them surgically, runs tests, and iterates on failures without
        any prompt parsing or file-block extraction on our side.
        """
        if query is None or ClaudeAgentOptions is None:
            raise RuntimeError(
                "claude-agent-sdk not installed. Run: pip install claude-agent-sdk"
            )

        import asyncio

        brief = self._build_brief(task)
        files_modified: List[str] = []
        final_text = ""

        options = ClaudeAgentOptions(
            permission_mode="acceptEdits",
            allowed_tools=["Read", "Write", "Edit", "Bash", "Glob", "Grep"],
            cwd=str(self._project_root),
            model=self.model,
            max_turns=self.max_turns,
        )

        async def _run() -> None:
            nonlocal final_text
            async for message in query(prompt=brief, options=options):
                # Collect result text
                if hasattr(message, "result") and message.result:
                    final_text = message.result
                # Track files written/edited from tool use events
                if hasattr(message, "type") and message.type == "tool_use":
                    name = getattr(message, "name", "")
                    inp = getattr(message, "input", {}) or {}
                    if name in ("Write", "Edit") and "file_path" in inp:
                        fp = inp["file_path"]
                        if fp not in files_modified:
                            files_modified.append(fp)

        try:
            asyncio.run(_run())
        except Exception as exc:
            raise RuntimeError(f"Claude Agent SDK execution failed: {exc}") from exc

        # Fall back to manifest if SDK didn't emit tool events
        if not files_modified:
            files_modified = list(task.file_manifest)

        self._finalize_task(task, files_modified, final_text or "Completed via Claude Agent SDK")
        return {"task_id": task.item.id, "outcome": "completed", "files_modified": files_modified}

    # ------------------------------------------------------------------
    # Shared helpers
    # ------------------------------------------------------------------

    def _build_brief(self, task: PipelineTask) -> str:
        """Build the execution prompt for a task."""
        lines = [
            f"# Task: {task.item.description}",
            f"Requester: {task.item.requester}",
            f"Priority: {task.item.priority}",
            "",
        ]

        if task.deliverables:
            lines.append("## Acceptance Criteria")
            lines.append("Your changes MUST satisfy ALL of the following:")
            for d in task.deliverables:
                name = d.get("name", "")
                desc = d.get("description", "")
                lines.append(f"- {name}" + (f": {desc}" if desc else ""))
            lines.append("")

        if task.file_manifest:
            lines.append("## Files to work on")
            for f in task.file_manifest:
                lines.append(f"- {f}")
            lines.append("")

        lines += [
            "## Instructions",
            "1. Read the relevant files first to understand the current state.",
            "2. Make the minimal changes needed to satisfy the acceptance criteria.",
            "3. Run any existing tests to verify your changes don't break anything.",
            "4. When done, summarize what you changed and why.",
        ]

        return "\n".join(lines)

    def _finalize_task(
        self,
        task: PipelineTask,
        files_modified: List[str],
        summary: str,
    ) -> None:
        """Attach self-review and submit for human approval."""
        self._queue.submit_review(
            task=task,
            files_modified=files_modified,
            changes_summary=summary,
            reviewer=f"worker/{self.executor}",
        )
        logger.info(
            "[worker] Task %s submitted for review (%d files modified)",
            task.item.id, len(files_modified),
        )

    def _anthropic_tool_schemas(self) -> List[Dict[str, Any]]:
        """Build Anthropic-format tool schemas for Read/Write/Edit/Bash/Glob/Grep."""
        return [
            {
                "name": "Read",
                "description": "Read a file's contents. Returns text with line numbers.",
                "input_schema": {
                    "type": "object",
                    "properties": {
                        "file_path": {"type": "string", "description": "Path to read"},
                    },
                    "required": ["file_path"],
                },
            },
            {
                "name": "Write",
                "description": "Write content to a file. Creates parent dirs if needed.",
                "input_schema": {
                    "type": "object",
                    "properties": {
                        "file_path": {"type": "string"},
                        "content": {"type": "string"},
                    },
                    "required": ["file_path", "content"],
                },
            },
            {
                "name": "Edit",
                "description": "Replace old_string with new_string in a file (must be unique).",
                "input_schema": {
                    "type": "object",
                    "properties": {
                        "file_path": {"type": "string"},
                        "old_string": {"type": "string"},
                        "new_string": {"type": "string"},
                    },
                    "required": ["file_path", "old_string", "new_string"],
                },
            },
            {
                "name": "Bash",
                "description": (
                    "Execute a shell command"
                    " (no pipes/chains, blocklisted dangerous commands)."
                ),
                "input_schema": {
                    "type": "object",
                    "properties": {
                        "command": {"type": "string"},
                    },
                    "required": ["command"],
                },
            },
            {
                "name": "Glob",
                "description": "Find files matching a glob pattern.",
                "input_schema": {
                    "type": "object",
                    "properties": {
                        "pattern": {"type": "string"},
                        "path": {"type": "string"},
                    },
                    "required": ["pattern"],
                },
            },
            {
                "name": "Grep",
                "description": "Search file contents with a regex pattern.",
                "input_schema": {
                    "type": "object",
                    "properties": {
                        "pattern": {"type": "string"},
                        "path": {"type": "string"},
                    },
                    "required": ["pattern"],
                },
            },
        ]

    # ------------------------------------------------------------------
    # Status tracking
    # ------------------------------------------------------------------

    def _set_running(self, task_id: str) -> None:
        with self._status_lock:
            self._status.state = "running"
            self._status.task_id = task_id
            self._status.started_at = datetime.now(timezone.utc).isoformat()
            self._status.completed_at = None
            self._status.last_error = None

    def _set_idle(self, task_id: str) -> None:
        with self._status_lock:
            self._status.state = "idle"
            self._status.task_id = None
            self._status.completed_at = datetime.now(timezone.utc).isoformat()
            self._status.tasks_completed += 1

    def _set_error(self, task_id: str, error: str) -> None:
        with self._status_lock:
            self._status.state = "error"
            self._status.task_id = task_id
            self._status.completed_at = datetime.now(timezone.utc).isoformat()
            self._status.last_error = error
