"""Parse natural language Slack messages into structured Dobby tasks."""
from __future__ import annotations

import asyncio
import json
import os
import re
import shutil
from typing import Any

# Regex to detect bullet-point or numbered-list items
_MULTI_ITEM_RE = re.compile(
    r"^\s*(?:[-*•]|\d+[.)]\s)", re.MULTILINE
)


def looks_like_batch(message: str) -> bool:
    """Return True if *message* contains multiple bullet/numbered items."""
    return len(_MULTI_ITEM_RE.findall(message)) >= 2


def split_batch_items(message: str) -> list[str]:
    """Split a message with bullet/numbered items into individual task strings.

    Any preamble text before the first item is prepended to every item
    as context (e.g. project name mentioned once at the top).
    """
    parts = _MULTI_ITEM_RE.split(message)
    # parts[0] is the preamble (before the first bullet)
    preamble = parts[0].strip()
    items: list[str] = []
    # Walk through the original message to get each item's text
    matches = list(_MULTI_ITEM_RE.finditer(message))
    for i, m in enumerate(matches):
        start = m.end()
        end = matches[i + 1].start() if i + 1 < len(matches) else len(message)
        item_text = message[start:end].strip()
        if item_text:
            # Prepend preamble for context if present
            full = f"{preamble} {item_text}" if preamble else item_text
            items.append(full)
    return items


class SlackParser:
    """Parse natural language Slack messages into structured task dicts."""

    _SYSTEM_PROMPT = """\
You are a task parser for Dobby, a meta-agent orchestrator.
Extract structured task information from the user's message.

Available projects: {projects}
Available spoke servers: {spokes}

Return a JSON object with these fields:
- project: (string) which project this task is for (must be from the available list, or null)
- description: (string) what needs to be done
- exit_criteria: (string or null) when the task is done
- priority: (string or null) "low", "normal", "high", or "urgent"
- target: (string or null) which spoke server to run on (from available list), or null for auto
- schedule_type: (string or null) "cron", "interval", or null
- schedule_expr: (string or null) cron expression or interval like "30m"
- mode: (string) "quick" or "planned"
- backend: (string or null) "claude" or "codex" if specified, otherwise null

Return ONLY the JSON object, no other text."""

    _CORRECTION_PROMPT = """\
You are updating a parsed task for Dobby. The user wants to change some fields.

Current task:
{current_task}

Apply the user's correction and return the updated JSON object with the same fields.
Only change fields the user explicitly mentions. Return ONLY the JSON object."""

    def __init__(self, anthropic_api_key: str | None = None) -> None:
        key = anthropic_api_key or os.environ.get("ANTHROPIC_API_KEY")
        if key:
            import anthropic
            self._client: Any = anthropic.AsyncAnthropic(api_key=key)
        else:
            self._client = None

    async def parse(
        self, message: str, available_projects: list[str], available_spokes: list[str] | None = None
    ) -> dict:
        """Parse *message* and return a structured task dict.

        Tries in order: Anthropic API → Claude Code CLI → regex fallback.
        """
        spokes = available_spokes or []
        if self._client is not None:
            return await self._parse_with_claude(message, available_projects, spokes)
        # Try Claude Code CLI (uses subscription, no API key needed)
        if shutil.which("claude") or os.path.exists(os.path.expanduser("~/.local/bin/claude")):
            result = await self._parse_with_claude_code(message, available_projects, spokes)
            if result.get("project"):
                return result
        return self._parse_fallback(message, available_projects)

    async def parse_batch(self, message: str, available_projects: list[str]) -> list[dict]:
        """Parse a multi-item message into a list of task dicts."""
        items = split_batch_items(message)
        if not items:
            return [await self.parse(message, available_projects)]
        results = []
        for item in items:
            parsed = await self.parse(item, available_projects)
            results.append(parsed)
        return results

    async def apply_correction(
        self, current: dict, correction: str, available_projects: list[str]
    ) -> dict:
        """Apply a natural-language correction to *current* task dict.

        Uses Claude API if available, then Claude Code CLI, then keyword fallback.
        """
        if self._client is not None:
            return await self._correct_with_claude(current, correction, available_projects)
        # Try Claude Code CLI for corrections too
        if shutil.which("claude") or os.path.exists(os.path.expanduser("~/.local/bin/claude")):
            combined = f"Current task: {json.dumps(current)}\n\nUser correction: {correction}"
            result = await self._parse_with_claude_code(combined, available_projects)
            if result.get("project"):
                merged = current.copy()
                merged.update({k: v for k, v in result.items() if v is not None})
                return self._normalise(merged)
        return self._correct_fallback(current, correction, available_projects)

    # ------------------------------------------------------------------
    # Private helpers
    # ------------------------------------------------------------------

    async def _parse_with_claude_code(
        self, message: str, available_projects: list[str],
        available_spokes: list[str] | None = None,
    ) -> dict:
        """Parse using Claude Code CLI (no API key needed, uses subscription)."""
        projects_str = ", ".join(available_projects) if available_projects else "(none)"
        spokes_str = ", ".join(available_spokes) if available_spokes else "(none)"
        prompt = (
            f"{self._SYSTEM_PROMPT.format(projects=projects_str, spokes=spokes_str)}\n\n"
            f"User message: {message}"
        )

        claude_bin = shutil.which("claude") or os.path.expanduser("~/.local/bin/claude")
        args = [claude_bin, "-p", prompt, "--output-format", "json", "--max-turns", "1"]

        env = os.environ.copy()
        local_bin = os.path.expanduser("~/.local/bin")
        if local_bin not in env.get("PATH", ""):
            env["PATH"] = local_bin + ":" + env.get("PATH", "")
        env["IS_SANDBOX"] = "1"

        try:
            proc = await asyncio.create_subprocess_exec(
                *args,
                stdin=asyncio.subprocess.DEVNULL,
                stdout=asyncio.subprocess.PIPE,
                stderr=asyncio.subprocess.PIPE,
                env=env,
            )
            stdout, _ = await asyncio.wait_for(proc.communicate(), timeout=30)
            raw = stdout.decode(errors="replace")

            # Parse the JSON envelope from Claude Code
            try:
                envelope = json.loads(raw)
                result_text = envelope.get("result", raw) if isinstance(envelope, dict) else raw
            except json.JSONDecodeError:
                result_text = raw

            # Extract JSON from the result
            m = re.search(r"\{.*\}", result_text, re.DOTALL)
            if m:
                data = json.loads(m.group())
                return self._normalise(data)
        except Exception:
            pass

        return self._parse_fallback(message, available_projects)

    async def _parse_with_claude(
        self, message: str, available_projects: list[str],
        available_spokes: list[str] | None = None,
    ) -> dict:
        projects_str = ", ".join(available_projects) if available_projects else "(none)"
        spokes_str = ", ".join(available_spokes) if available_spokes else "(none)"
        system = self._SYSTEM_PROMPT.format(projects=projects_str, spokes=spokes_str)

        response = await self._client.messages.create(
            model="claude-haiku-4-5-20251001",
            max_tokens=512,
            system=system,
            messages=[{"role": "user", "content": message}],
        )

        raw = response.content[0].text.strip()
        try:
            data = json.loads(raw)
        except json.JSONDecodeError:
            # Best-effort: try to extract JSON from within the text
            m = re.search(r"\{.*\}", raw, re.DOTALL)
            data = json.loads(m.group()) if m else {}

        return self._normalise(data)

    async def _correct_with_claude(
        self, current: dict, correction: str, available_projects: list[str]
    ) -> dict:
        projects_str = ", ".join(available_projects) if available_projects else "(none)"
        system = self._CORRECTION_PROMPT.format(current_task=json.dumps(current, indent=2))
        system += f"\n\nAvailable projects: {projects_str}"

        response = await self._client.messages.create(
            model="claude-haiku-4-5-20251001",
            max_tokens=512,
            system=system,
            messages=[{"role": "user", "content": correction}],
        )

        raw = response.content[0].text.strip()
        try:
            data = json.loads(raw)
        except json.JSONDecodeError:
            m = re.search(r"\{.*\}", raw, re.DOTALL)
            data = json.loads(m.group()) if m else current.copy()

        merged = current.copy()
        merged.update({k: v for k, v in data.items() if v is not None or k in current})
        return self._normalise(merged)

    def _correct_fallback(
        self, current: dict, correction: str, available_projects: list[str]
    ) -> dict:
        """Apply simple keyword-based corrections."""
        updated = current.copy()
        lower = correction.lower()

        # Backend correction
        if "codex" in lower:
            updated["backend"] = "codex"
        elif "claude" in lower and "backend" not in lower:
            updated["backend"] = "claude"

        # Priority correction
        for p in ("urgent", "high", "normal", "low"):
            if p in lower and ("priority" in lower or p in ("urgent",)):
                updated["priority"] = p
                break

        # Mode correction
        if "planned" in lower or "plan mode" in lower:
            updated["mode"] = "planned"
        elif "quick" in lower:
            updated["mode"] = "quick"

        # Project correction
        for proj in available_projects:
            if proj.lower() in lower:
                updated["project"] = proj
                break

        return self._normalise(updated)

    def _parse_fallback(self, message: str, available_projects: list[str]) -> dict:
        """Simple regex-based fallback when no API key is configured."""
        msg_lower = message.lower()

        # Project detection
        project = None
        for proj in available_projects:
            if proj.lower() in msg_lower:
                project = proj
                break

        # Priority keywords
        priority = "normal"
        if any(w in msg_lower for w in ("urgent", "asap", "immediately")):
            priority = "urgent"
        elif any(w in msg_lower for w in ("high priority", "important")):
            priority = "high"
        elif "low priority" in msg_lower:
            priority = "low"

        # Schedule detection
        schedule_type = None
        schedule_expr = None
        cron_match = re.search(
            r"every\s+(night|morning|day|hour|week)(\s+at\s+(\d{1,2})(am|pm)?)?", msg_lower
        )
        interval_match = re.search(
            r"every\s+(\d+)\s*(m(?:in(?:utes?)?)?|h(?:ours?)?|s(?:ec(?:onds?)?)?)", msg_lower
        )
        if cron_match:
            schedule_type = "cron"
            word = cron_match.group(1)
            time_part = cron_match.group(3)
            hour = int(time_part) if time_part else 0
            if cron_match.group(4) == "pm" and hour != 12:
                hour += 12
            schedule_expr = {
                "night": f"0 {hour or 22} * * *",
                "morning": f"0 {hour or 8} * * *",
                "day": f"0 {hour or 9} * * *",
                "hour": "0 * * * *",
                "week": f"0 {hour or 9} * * 1",
            }.get(word, f"0 {hour} * * *")
        elif interval_match:
            schedule_type = "interval"
            num = interval_match.group(1)
            unit_raw = interval_match.group(2)[0]  # first char: m/h/s
            unit_map = {"m": "m", "h": "h", "s": "s"}
            schedule_expr = f"{num}{unit_map.get(unit_raw, 'm')}"

        # Mode: planned if message mentions "plan" or "draft"
        mode = "planned" if re.search(r"\bplan\b|\bdraft\b|\bdesign\b", msg_lower) else "quick"

        # Exit criteria: look for "until", "when", "once"
        exit_criteria = None
        ec_match = re.search(
            r"(?:until|when|once|so that|exit when)\s+(.+?)(?:\.|,|$)", message, re.IGNORECASE
        )
        if ec_match:
            exit_criteria = ec_match.group(1).strip()

        # Description: strip project name and schedule fragments
        description = message
        if project:
            description = re.sub(re.escape(project), "", description, flags=re.IGNORECASE)
        description = re.sub(
            r"every\s+\w+(\s+at\s+\d+(?:am|pm)?)?", "", description, flags=re.IGNORECASE
        )
        description = re.sub(r"\s+", " ", description).strip().strip(",").strip()

        return {
            "project": project,
            "description": description or message,
            "exit_criteria": exit_criteria,
            "priority": priority,
            "schedule_type": schedule_type,
            "schedule_expr": schedule_expr,
            "mode": mode,
        }

    @staticmethod
    def _normalise(data: dict) -> dict:
        """Ensure all expected keys exist with sane defaults."""
        defaults: dict[str, Any] = {
            "project": None,
            "description": "",
            "exit_criteria": None,
            "priority": "normal",
            "schedule_type": None,
            "schedule_expr": None,
            "mode": "quick",
            "backend": None,
        }
        defaults.update(data)
        return defaults
