import json
import logging
from typing import Any, List, Optional, Tuple

from sglang.srt.entrypoints.openai.protocol import Tool
from sglang.srt.function_call.base_format_detector import BaseFormatDetector
from sglang.srt.function_call.core_types import (
    StreamingParseResult,
    StructureInfo,
    ToolCallItem,
    _GetInfoFunc,
)
from sglang.srt.function_call.utils import _is_complete_json

logger = logging.getLogger(__name__)


class MistralDetector(BaseFormatDetector):
    """
    Detector for Mistral tool/function call formats.

    Supported formats:

    1) JSON-array format:
       `[TOOL_CALLS] [{"name": "...", "arguments": {...}}, ...]`

    2) Compact format (common in newer templates/models, especially in streaming):
       `[TOOL_CALLS]tool_name[ARGS]{...}`
       (also tolerates missing delimiters like `]` after `[TOOL_CALLS` and/or `[ARGS]` while streaming)

    Reference: https://huggingface.co/mistralai/Mistral-7B-Instruct-v0.3?chat_template=default
    """

    def __init__(self):
        """Initialize tokens and streaming state."""
        super().__init__()
        # Canonical Mistral prefix for JSON-array tool calls.
        self.bot_token = "[TOOL_CALLS] ["
        # Common marker shared by both JSON-array and compact formats.
        self._tool_calls_marker = "[TOOL_CALLS"
        self.eot_token = "]"
        self.tool_call_separator = ", "

    def has_tool_call(self, text: str) -> bool:
        """Return True if the text contains either supported tool-call marker."""
        return self._tool_calls_marker in text

    def detect_and_parse(self, text: str, tools: List[Tool]) -> StreamingParseResult:
        """
        One-time parsing: Detects and parses tool calls in the provided text.

        :param text: The complete text to parse.
        :param tools: List of available tools.
        :return: ParseResult indicating success or failure, consumed text, leftover text, and parsed calls.
        """
        marker_idx = text.find(self._tool_calls_marker)
        if marker_idx == -1:
            return StreamingParseResult(normal_text=text, calls=[])

        normal_text = text[:marker_idx].strip()
        tool_part = text[marker_idx:]

        # Canonical: `[TOOL_CALLS] [{...}, ...]`
        if self.bot_token in tool_part:
            json_array_str = self._extract_json_array(tool_part)
            if not json_array_str:
                return StreamingParseResult(normal_text=normal_text, calls=[])

            calls: list = []
            try:
                function_call_arr = json.loads(json_array_str)
                if not isinstance(function_call_arr, list):
                    function_call_arr = [function_call_arr]
                calls = self.parse_base_json(function_call_arr, tools)
            except json.JSONDecodeError as e:
                logger.warning(
                    f"Failed to parse JSON part: {json_array_str}, JSON parse error: {str(e)}"
                )
            json_pos = tool_part.find(json_array_str) if json_array_str else -1
            trailing_text = (
                tool_part[json_pos + len(json_array_str) :].strip()
                if json_pos != -1
                else ""
            )
            combined_normal = (
                (normal_text + " " + trailing_text).strip()
                if trailing_text
                else normal_text
            )
            return StreamingParseResult(normal_text=combined_normal, calls=calls)

        # Compact: `[TOOL_CALLS]tool_name[ARGS]{...}`
        # Loop to extract all consecutive compact tool calls.
        all_calls: list = []
        remaining = tool_part
        while remaining:
            parsed = self._try_parse_compact_args_format(remaining)
            if not parsed:
                break
            func_name, args_obj, consumed = parsed
            new_calls = self.parse_base_json(
                {"name": func_name, "arguments": args_obj}, tools
            )
            all_calls.extend(new_calls)
            remaining = remaining[consumed:].strip()

        if not all_calls:
            return StreamingParseResult(normal_text=normal_text, calls=[])

        combined_normal = (
            (normal_text + " " + remaining).strip() if remaining else normal_text
        )
        return StreamingParseResult(normal_text=combined_normal, calls=all_calls)

    def parse_streaming_increment(
        self, new_text: str, tools: List[Tool]
    ) -> StreamingParseResult:
        """
        Streaming parsing for both JSON-array and compact formats.

        For the compact format, this buffers until the JSON arguments payload is complete,
        then emits two items: tool name (with empty parameters) and a full arguments JSON
        chunk (OpenAI streaming semantics).
        """
        self._buffer += new_text
        current_text = self._buffer

        # No marker: either flush as normal text or keep buffering a partial marker.
        if self._tool_calls_marker not in current_text:
            if not self._ends_with_partial_token(self._buffer, self._tool_calls_marker):
                normal_text = self._buffer
                self._buffer = ""
                if self.eot_token in normal_text:
                    normal_text = normal_text.replace(self.eot_token, "")
                return StreamingParseResult(normal_text=normal_text)
            return StreamingParseResult()

        # If there's leading normal text before the marker, stream it out first.
        marker_pos = current_text.find(self._tool_calls_marker)
        if marker_pos > 0:
            normal_text = current_text[:marker_pos]
            self._buffer = current_text[marker_pos:]
            return StreamingParseResult(normal_text=normal_text)

        # Build tool indices if not already built.
        if not hasattr(self, "_tool_indices"):
            self._tool_indices = self._get_tool_indices(tools)

        # Try compact first; JSON-array requires `] [` and often arrives later in streaming.
        compact = self._try_parse_compact_args_format(current_text)
        if compact:
            func_name, args_obj, consumed = compact
            if func_name not in self._tool_indices:
                # Unknown tool: treat as normal text and reset state.
                normal_text = self._buffer
                self._buffer = ""
                return StreamingParseResult(normal_text=normal_text)

            # Initialize state if this is the first tool call.
            if self.current_tool_id == -1:
                self.current_tool_id = 0
                self.prev_tool_call_arr = []
                self.streamed_args_for_tool = []

            args_json = json.dumps(args_obj, ensure_ascii=False)
            tool_id = self.current_tool_id

            # Ensure arrays are large enough.
            while len(self.prev_tool_call_arr) <= tool_id:
                self.prev_tool_call_arr.append({})
            while len(self.streamed_args_for_tool) <= tool_id:
                self.streamed_args_for_tool.append("")

            self.prev_tool_call_arr[tool_id] = {
                "name": func_name,
                "arguments": args_obj,
            }
            self.streamed_args_for_tool[tool_id] = args_json

            calls: List[ToolCallItem] = [
                ToolCallItem(tool_index=tool_id, name=func_name, parameters=""),
                ToolCallItem(tool_index=tool_id, name=None, parameters=args_json),
            ]

            # Consume parsed content from buffer.
            self._buffer = current_text[consumed:]
            self.current_tool_id += 1
            self.current_tool_name_sent = False
            return StreamingParseResult(normal_text="", calls=calls)

        # Canonical format delegates to the BaseFormatDetector JSON streaming logic.
        if self.bot_token in current_text:
            return super().parse_streaming_increment(new_text="", tools=tools)

        # Otherwise, keep buffering.
        return StreamingParseResult()

    def _try_parse_compact_args_format(
        self, text: str
    ) -> Optional[Tuple[str, Any, int]]:
        """
        Parse the compact tool call format:
            `[TOOL_CALLS]tool_name[ARGS]{...}`

        Tolerates common streaming variants where delimiters are missing:
            `[TOOL_CALLStool_name[ARGS{...}`

        Returns:
            (tool_name, arguments_obj, consumed_end_index) if a complete JSON arguments
            payload is present; otherwise None.
        """
        start = text.find(self._tool_calls_marker)
        if start == -1:
            return None

        i = start + len(self._tool_calls_marker)  # position after "[TOOL_CALLS"
        if i < len(text) and text[i] == "]":
            i += 1
        while i < len(text) and text[i].isspace():
            i += 1

        args_marker = "[ARGS"
        args_pos = text.find(args_marker, i)
        if args_pos == -1:
            return None

        func_name = text[i:args_pos].strip()
        if not func_name:
            return None

        j = args_pos + len(args_marker)
        if j < len(text) and text[j] == "]":
            j += 1
        while j < len(text) and text[j].isspace():
            j += 1

        if j >= len(text) or text[j] not in "{[":
            return None

        json_str, end_idx = self._extract_json_value(text, j)
        if not json_str:
            return None
        if not _is_complete_json(json_str):
            return None

        try:
            args_obj = json.loads(json_str)
        except json.JSONDecodeError:
            return None

        return func_name, args_obj, end_idx

    def _extract_json_value(
        self, text: str, json_start: int
    ) -> Tuple[Optional[str], int]:
        """
        Extract a JSON value (object or array) starting at json_start using bracket counting,
        robust to nested braces/brackets inside strings.

        Returns:
            (json_str_or_None, end_index_exclusive)
        """
        if json_start >= len(text) or text[json_start] not in "{[":
            return None, json_start

        opening = text[json_start]
        closing = "}" if opening == "{" else "]"
        depth = 0
        in_string = False
        escape_next = False

        for k in range(json_start, len(text)):
            ch = text[k]
            if escape_next:
                escape_next = False
                continue
            if ch == "\\":
                escape_next = True
                continue
            if ch == '"' and not escape_next:
                in_string = not in_string
                continue
            if in_string:
                continue
            if ch == opening:
                depth += 1
            elif ch == closing:
                depth -= 1
                if depth == 0:
                    return text[json_start : k + 1], k + 1

        return None, json_start

    def _extract_json_array(self, text: str) -> str:
        """
        Extract the JSON array part using bracket counting to handle nested brackets.

        :param text: The complete text containing [TOOL_CALLS] [...]
        :return: The JSON array string or None if not found
        """
        start_idx = text.find(self.bot_token)
        if start_idx == -1:
            return None

        # Start from the opening bracket after [TOOL_CALLS]
        json_start = (
            start_idx + len(self.bot_token) - 1
        )  # -1 to include the opening bracket
        bracket_count = 0
        in_string = False
        escape_next = False

        for i in range(json_start, len(text)):
            char = text[i]

            if escape_next:
                escape_next = False
                continue

            if char == "\\":
                escape_next = True
                continue

            if char == '"' and not escape_next:
                in_string = not in_string
                continue

            if not in_string:
                if char == "[":
                    bracket_count += 1
                elif char == "]":
                    bracket_count -= 1
                    if bracket_count == 0:
                        return text[json_start : i + 1]

        return None

    def structure_info(self) -> _GetInfoFunc:
        return lambda name: StructureInfo(
            begin='[TOOL_CALLS] [{"name":"' + name + '", "arguments":',
            end="}]",
            trigger="[TOOL_CALLS]",
        )
