"""ClaudeChatMixin — free-form Claude Code conversations and thread continuations."""
from __future__ import annotations

import asyncio
import contextlib
import json
import logging
import os
import re
from typing import Any

from dobby.slack.state import (
    _CLAUDE_SESSIONS,
    _save_claude_sessions,
)

logger = logging.getLogger(__name__)


class ClaudeChatMixin:
    """Claude Code free-form conversation methods."""

    # These attributes are provided by DobbySlackBot.__init__:
    #   self._client_wrapper, self.app

    async def _detect_project_cwd(self, text: str) -> str | None:
        """Try to match a project name in the message and return its repo path."""
        projects = await self._get_projects()
        lower = text.lower()
        for name in projects:
            if name.lower() in lower:
                try:
                    proj = await self._client_wrapper.get(f"/projects/{name}")
                    repo = proj.get("repo") if isinstance(proj, dict) else None
                    if repo and os.path.isdir(repo):
                        return repo
                except Exception:
                    pass
        return None

    async def _handle_claude_freeform(
        self, text: str, event: dict, say: Any, client: Any = None
    ) -> None:
        """Spawn Claude Code for free-form messages and reply in thread."""
        channel = event.get("channel", "")
        msg_ts = event.get("ts", "")
        logger.info("Claude freeform: channel=%s msg_ts=%s text=%s", channel, msg_ts, text[:80])

        # Detect project from message to set working directory
        cwd = await self._detect_project_cwd(text)

        # Post typing indicator in thread
        typing_ts = None
        if client:
            try:
                typing_resp = await client.chat_postMessage(
                    channel=channel, thread_ts=msg_ts,
                    text=":hourglass_flowing_sand: _Dobby is thinking..._",
                )
                typing_ts = typing_resp.get("ts")
            except Exception:
                pass

        try:
            output, session_id = await self._run_claude(text, session_id=None, cwd=cwd)
        except Exception as exc:
            logger.warning("Claude Code freeform failed: %s", exc)
            # Remove typing indicator
            if client and typing_ts:
                with contextlib.suppress(Exception):
                    await client.chat_delete(channel=channel, ts=typing_ts)
            if client:
                await client.chat_postMessage(
                    channel=channel, thread_ts=msg_ts,
                    text=f":warning: Claude Code error: `{exc}`",
                )
            return

        response_text = self._format_claude_response(output)

        # Replace typing indicator with actual response
        if client and typing_ts:
            try:
                await client.chat_update(
                    channel=channel, ts=typing_ts,
                    text=response_text,
                )
            except Exception:
                # Fallback: post new message if update fails
                await client.chat_postMessage(
                    channel=channel, thread_ts=msg_ts,
                    text=response_text,
                )
        elif client:
            await client.chat_postMessage(
                channel=channel, thread_ts=msg_ts,
                text=response_text,
            )

        # Track the thread for continuation using the original message ts
        if msg_ts and session_id:
            _CLAUDE_SESSIONS[msg_ts] = {"session_id": session_id, "cwd": cwd}
            _save_claude_sessions()

    async def _handle_claude_thread(
        self, thread_ts: str, text: str, event: dict, say: Any, client: Any = None
    ) -> None:
        """Continue a Claude Code conversation in a thread."""
        channel = event.get("channel", "")
        session_info = _CLAUDE_SESSIONS.get(thread_ts) or {}
        session_id = (
            session_info.get("session_id") if isinstance(session_info, dict) else session_info
        )
        cwd = session_info.get("cwd") if isinstance(session_info, dict) else None
        logger.info(
            "Claude thread: thread_ts=%s session_id=%s text=%s", thread_ts, session_id, text[:80]
        )

        # Post typing indicator in thread
        typing_ts = None
        if client:
            try:
                typing_resp = await client.chat_postMessage(
                    channel=channel, thread_ts=thread_ts,
                    text=":hourglass_flowing_sand: _Dobby is thinking..._",
                )
                typing_ts = typing_resp.get("ts")
            except Exception:
                pass

        try:
            output, new_session_id = await self._run_claude(text, session_id=session_id, cwd=cwd)
        except Exception as exc:
            logger.warning("Claude Code thread reply failed: %s", exc)
            if client and typing_ts:
                with contextlib.suppress(Exception):
                    await client.chat_delete(channel=channel, ts=typing_ts)
            if client:
                await client.chat_postMessage(
                    channel=channel, thread_ts=thread_ts,
                    text=f":warning: Claude Code error: `{exc}`",
                )
            return

        response_text = self._format_claude_response(output)

        # Replace typing indicator with actual response
        if client and typing_ts:
            try:
                await client.chat_update(
                    channel=channel, ts=typing_ts,
                    text=response_text,
                )
            except Exception:
                await client.chat_postMessage(
                    channel=channel, thread_ts=thread_ts,
                    text=response_text,
                )
        elif client:
            await client.chat_postMessage(
                channel=channel, thread_ts=thread_ts,
                text=response_text,
            )
        else:
            await say(response_text)

        # Update session ID if it changed
        if new_session_id:
            _CLAUDE_SESSIONS[thread_ts] = {"session_id": new_session_id, "cwd": cwd}
            _save_claude_sessions()

    async def _run_claude(
        self, prompt: str, session_id: str | None = None, cwd: str | None = None
    ) -> tuple[str, str | None]:
        """Run Claude Code and return (output_text, session_id).

        Args:
            prompt: The user's message.
            session_id: Existing session to resume.
            cwd: Working directory -- defaults to Dobby repo if not set.
        """
        import shutil
        claude_bin = shutil.which("claude") or os.path.expanduser("~/.local/bin/claude")

        # Append Slack formatting instruction to the prompt
        slack_prompt = (
            prompt
            + "\n\n[Format your reply for Slack: use *bold* not **bold**,"
            " use \u2022 for bullets not -, no markdown headers (#),"
            " keep code blocks with ```. Be concise.]"
        )

        args = [
            claude_bin,
            "-p", slack_prompt,
            "--output-format", "json",
            "--max-turns", "20",
            "--dangerously-skip-permissions",
        ]
        if session_id:
            args.extend(["--resume", session_id])

        env = os.environ.copy()
        # Ensure ~/.local/bin is on PATH for child process
        local_bin = os.path.expanduser("~/.local/bin")
        if local_bin not in env.get("PATH", ""):
            env["PATH"] = local_bin + ":" + env.get("PATH", "")
        # Allow --dangerously-skip-permissions as root
        env["IS_SANDBOX"] = "1"

        proc = await asyncio.create_subprocess_exec(
            *args,
            stdin=asyncio.subprocess.DEVNULL,
            stdout=asyncio.subprocess.PIPE,
            stderr=asyncio.subprocess.PIPE,
            env=env,
            cwd=cwd,
            limit=5 * 1024 * 1024,
        )
        stdout_bytes, stderr_bytes = await asyncio.wait_for(
            proc.communicate(), timeout=300,
        )

        raw = stdout_bytes.decode(errors="replace")
        err = stderr_bytes.decode(errors="replace")
        if err:
            logger.warning("Claude stderr: %s", err[:500])
        if proc.returncode != 0:
            logger.warning("Claude exited with code %d", proc.returncode)

        # Parse the JSON output to extract result and session_id
        result_text = raw
        new_session_id = None
        try:
            envelope = json.loads(raw)
            if isinstance(envelope, dict):
                result_text = envelope.get("result", raw)
                new_session_id = envelope.get("session_id")
        except (json.JSONDecodeError, TypeError):
            pass

        return result_text, new_session_id

    @staticmethod
    def _md_to_slack(text: str) -> str:
        """Convert standard markdown to Slack mrkdwn format."""
        # Headers -> bold text
        text = re.sub(r'^#{1,3}\s+(.+)$', r'*\1*', text, flags=re.MULTILINE)
        # Bold: **text** -> *text*
        text = re.sub(r'\*\*(.+?)\*\*', r'*\1*', text)
        # Links: [text](url) -> <url|text>
        text = re.sub(r'\[([^\]]+)\]\(([^)]+)\)', r'<\2|\1>', text)
        # Unordered lists: - item -> bullet item
        text = re.sub(r'^(\s*)[-*]\s+', r'\1\u2022 ', text, flags=re.MULTILINE)
        # Horizontal rules
        text = re.sub(r'^---+$', '\u2014' * 20, text, flags=re.MULTILINE)
        return text

    @staticmethod
    def _format_claude_response(output: str) -> str:
        """Format Claude Code output for Slack, truncating if needed."""
        text = output.strip()
        if not text:
            return ":shrug: Claude returned no output."

        # Convert markdown to Slack format
        text = ClaudeChatMixin._md_to_slack(text)

        # Slack has a ~4000 char limit per message; truncate gracefully
        max_len = 3800
        if len(text) > max_len:
            text = text[:max_len] + "\n\n_(truncated \u2014 response too long)_"

        return text
