# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""On-demand trace builder for parser engine testing and benchmarks.

Generates token sequences programmatically from model-agnostic scenario
definitions.  Each model format handler knows how to render scenarios
into the model's output format, tokenize them with correct special token
IDs, and compute expected parse outputs.

Every generated sample is self-validated by replaying it through the
real parser before being returned.
"""

from __future__ import annotations

import functools
import json
from dataclasses import dataclass
from typing import Any

from tests.parser.engine.replay_harness import (
    MockTokenizer,
    Sample,
    assert_parse_output,
    collect_output,
    replay_streaming,
)
from vllm.entrypoints.openai.chat_completion.protocol import (
    ChatCompletionToolsParam,
)
from vllm.parser.engine.registered_adapters import (
    Gemma4Parser,
    Glm47MoeParser,
    MinimaxM2Parser,
    NemotronV3Parser,
    Qwen3Parser,
    SeedOssParser,
)

# ── Data structures ──────────────────────────────────────────────────


@dataclass
class ToolCallSpec:
    name: str
    arguments: dict[str, Any]


@dataclass
class Scenario:
    id: str
    description: str
    reasoning: str | None = None
    content: str | None = None
    tool_calls: list[ToolCallSpec] | None = None
    after_tool_response: bool = False


# ── Scenarios ────────────────────────────────────────────────────────

_READ_TOOL = ToolCallSpec("read_file", {"path": "/tmp/test.txt"})
_BASH_TOOL = ToolCallSpec(
    "bash", {"command": "hostname", "description": "Get hostname"}
)
_WEATHER_TOOL = ToolCallSpec(
    "get_weather",
    {"city": "Dallas", "state": "TX", "unit": "fahrenheit"},
)
_COMPLEX_TOOL = ToolCallSpec(
    "search",
    {
        "query": "vllm parser",
        "filters": {"language": "python", "min_stars": 100},
        "tags": ["ml", "inference"],
        "limit": 10,
        "verbose": True,
    },
)

SCENARIOS: list[Scenario] = [
    Scenario(
        id="think-then-tool",
        description="Reasoning then single tool call",
        reasoning="Let me check the file.",
        tool_calls=[_READ_TOOL],
    ),
    Scenario(
        id="think-then-parallel-tools",
        description="Reasoning then two parallel tool calls",
        reasoning="I need to run both commands.",
        tool_calls=[_BASH_TOOL, _WEATHER_TOOL],
    ),
    Scenario(
        id="think-then-content",
        description="Reasoning then content response",
        reasoning="Let me think about this carefully.",
        content="The answer is 42.",
    ),
    Scenario(
        id="content-only",
        description="Plain content response without reasoning",
        content="Hello! How can I help you today?",
    ),
    Scenario(
        id="tool-only",
        description="Tool call without reasoning",
        tool_calls=[_READ_TOOL],
    ),
    Scenario(
        id="complex-json-args",
        description="Tool call with nested objects, arrays, numbers, booleans",
        reasoning="This needs a complex query.",
        tool_calls=[_COMPLEX_TOOL],
    ),
    Scenario(
        id="whitespace-before-tool",
        description="Whitespace-only content before tool call",
        content="\n\n",
        tool_calls=[_WEATHER_TOOL],
    ),
    Scenario(
        id="think-content-tool",
        description="Reasoning, content, then tool call",
        reasoning="Let me analyze and then fetch data.",
        content="Checking the weather now.",
        tool_calls=[_WEATHER_TOOL],
    ),
    Scenario(
        id="think-whitespace-tool",
        description="Reasoning, whitespace-only gap, then tool call",
        reasoning="Let me check the file contents.",
        content="\n\n",
        tool_calls=[_READ_TOOL],
    ),
    Scenario(
        id="empty-reasoning-content",
        description="Empty reasoning section followed by content",
        reasoning="",
        content="The epoch timestamp is 1779111346.",
    ),
    Scenario(
        id="tool-after-tool-response",
        description="Tool call immediately after tool response (agentic flow)",
        tool_calls=[_READ_TOOL],
        after_tool_response=True,
    ),
    Scenario(
        id="empty-tool-block",
        description="Empty tool block followed by content (edge case recovery)",
        content="Content after empty tools.",
        tool_calls=[],
    ),
]


# ── Tokenization ─────────────────────────────────────────────────────


def _word_split(text: str) -> list[str]:
    """Split text into word-like tokens, preserving all characters."""
    if not text:
        return []
    parts: list[str] = []
    current = ""
    for ch in text:
        if ch in " \t\n\r" and current and current[-1] not in " \t\n\r":
            parts.append(current)
            current = ch
        else:
            current += ch
    if current:
        parts.append(current)
    return parts


def _tokenize(
    segments: list[tuple[str, bool]],
    vocab: dict[str, int],
    start_id: int = 100,
) -> list[tuple[int, str]]:
    """Build token list from segments.

    Each segment is ``(text, is_special)``.  Special segments use vocab
    IDs; content segments are word-split with sequential IDs.
    """
    tokens: list[tuple[int, str]] = []
    next_id = start_id

    for text, is_special in segments:
        if not text:
            continue
        if is_special:
            tid = vocab.get(text)
            if tid is None:
                raise ValueError(f"Special token {text!r} not in vocab")
            tokens.append((tid, text))
        else:
            for word in _word_split(text):
                tokens.append((next_id, word))
                next_id += 1

    return tokens


# ── Tool definitions ─────────────────────────────────────────────────


def _infer_schema(value: object) -> dict:
    """Infer a JSON Schema from a Python value, recursing into dicts/lists."""
    if isinstance(value, bool):
        return {"type": "boolean"}
    if isinstance(value, int):
        return {"type": "integer"}
    if isinstance(value, float):
        return {"type": "number"}
    if isinstance(value, str):
        return {"type": "string"}
    if isinstance(value, dict):
        return {
            "type": "object",
            "properties": {k: _infer_schema(v) for k, v in value.items()},
        }
    if isinstance(value, list) and value:
        return {"type": "array", "items": _infer_schema(value[0])}
    if isinstance(value, list):
        return {"type": "array"}
    return {}


def _tool_defs(tool_calls: list[ToolCallSpec]) -> list[dict]:
    """Generate OpenAI-style tool definitions from tool call specs."""
    seen: set[str] = set()
    tools: list[dict] = []
    for tc in tool_calls:
        if tc.name in seen:
            continue
        seen.add(tc.name)
        properties = {k: _infer_schema(v) for k, v in tc.arguments.items()}
        tools.append(
            {
                "type": "function",
                "function": {
                    "name": tc.name,
                    "parameters": {
                        "type": "object",
                        "properties": properties,
                    },
                },
            }
        )
    return tools


# ── Format handlers ──────────────────────────────────────────────────


def _expected_tc(scenario: Scenario) -> list[dict] | None:
    if not scenario.tool_calls:
        return None
    return [{"name": tc.name, "arguments": tc.arguments} for tc in scenario.tool_calls]


def _expected_tools(scenario: Scenario) -> list[dict] | None:
    return _tool_defs(scenario.tool_calls) if scenario.tool_calls else None


def _validate_sample(sample: Sample, parser_cls: type, **kwargs) -> None:
    """Replay sample through the real parser and assert correctness."""
    tokenizer = MockTokenizer(vocab=dict(sample.vocab), tokens=sample.tokens)
    parser = parser_cls(tokenizer, sample.tools, **kwargs)
    deltas = replay_streaming(
        parser,
        sample.tokens,
        chunk_size=1,
        tools=sample.tools,
        prompt_token_ids=sample.prompt_token_ids,
    )
    output = collect_output(deltas)
    assert_parse_output(output, sample)


def _validate_tools(
    tools: list[dict] | None,
) -> list[ChatCompletionToolsParam] | None:
    if not tools:
        return None
    return [ChatCompletionToolsParam.model_validate(t) for t in tools]


def _make_sample(
    sample_id: str,
    description: str,
    vocab: dict[str, int],
    segments: list[tuple[str, bool]],
    expected_reasoning: str | None,
    expected_content: str | None,
    expected_tool_calls: list[dict] | None,
    tools: list[dict] | None,
    chat_template_kwargs: dict | None = None,
    prompt_token_ids: list[int] | None = None,
) -> Sample:
    tokens = _tokenize(segments, vocab)
    return Sample(
        id=sample_id,
        description=description,
        source="trace-builder",
        vocab=dict(vocab),
        tokens=tokens,
        expected_reasoning=expected_reasoning,
        expected_content=expected_content,
        expected_tool_calls=expected_tool_calls,
        tools=_validate_tools(tools),
        chat_template_kwargs=chat_template_kwargs,
        prompt_token_ids=prompt_token_ids,
    )


# ── Qwen3 (XML tool format, starts in REASONING) ────────────────────

_QWEN3_VOCAB: dict[str, int] = {
    "<think>": 50,
    "</think>": 51,
    "<tool_call>": 60,
    "</tool_call>": 61,
}


def _qwen3_arg_value(value: Any) -> str:
    if isinstance(value, bool):
        return "true" if value else "false"
    if isinstance(value, (int, float)):
        return str(value)
    if isinstance(value, str):
        return value
    return json.dumps(value, ensure_ascii=False)


def _qwen3_tool_segments(tc: ToolCallSpec) -> list[tuple[str, bool]]:
    parts = [f"\n<function={tc.name}>"]
    for key, value in tc.arguments.items():
        parts.append(f"\n<parameter={key}>{_qwen3_arg_value(value)}</parameter>")
    parts.append("\n</function>\n")
    return [
        ("<tool_call>", True),
        ("".join(parts), False),
        ("</tool_call>", True),
    ]


def _qwen3_segments(scenario: Scenario) -> list[tuple[str, bool]]:
    segs: list[tuple[str, bool]] = []
    if scenario.reasoning is not None:
        segs.append((scenario.reasoning, False))
    if scenario.content is not None or scenario.tool_calls is not None:
        segs.append(("</think>", True))
    if scenario.tool_calls is not None and not scenario.tool_calls:
        segs.append(("<tool_call>", True))
        segs.append(("</tool_call>", True))
    if scenario.content is not None:
        segs.append((scenario.content, False))
    if scenario.tool_calls:
        for tc in scenario.tool_calls:
            segs.extend(_qwen3_tool_segments(tc))
    return segs


def _qwen3_expected_content(scenario: Scenario) -> str | None:
    if (
        scenario.content is not None
        and scenario.tool_calls
        and not scenario.content.strip()
    ):
        return ""
    return scenario.content


def _build_qwen3(
    scenario: Scenario,
    name: str = "qwen3",
    parser_cls: type = Qwen3Parser,
    strip_trailing_ws: bool = False,
    validate: bool = True,
) -> Sample:
    expected_reasoning: str | None
    if scenario.reasoning is not None:
        r = scenario.reasoning
        if strip_trailing_ws:
            r = r.rstrip()
        expected_reasoning = r
    else:
        expected_reasoning = ""

    sample = _make_sample(
        sample_id=f"{name}-{scenario.id}",
        description=scenario.description,
        vocab=_QWEN3_VOCAB,
        segments=_qwen3_segments(scenario),
        expected_reasoning=expected_reasoning,
        expected_content=_qwen3_expected_content(scenario),
        expected_tool_calls=_expected_tc(scenario),
        tools=_expected_tools(scenario),
    )
    if validate:
        _validate_sample(sample, parser_cls)
    return sample


# ── MiniMax M2 (XML invoke format, starts in REASONING) ──────────────

_MINIMAX_M2_VOCAB: dict[str, int] = {
    "<think>": 50,
    "</think>": 51,
    "<minimax:tool_call>": 60,
    "</minimax:tool_call>": 61,
}


def _minimax_m2_arg_value(value: Any) -> str:
    if isinstance(value, bool):
        return "true" if value else "false"
    if isinstance(value, (int, float)):
        return str(value)
    if isinstance(value, str):
        return value
    return json.dumps(value, ensure_ascii=False)


def _minimax_m2_tool_segments(tool_calls: list[ToolCallSpec]) -> list[tuple[str, bool]]:
    segs: list[tuple[str, bool]] = [("<minimax:tool_call>", True)]
    for tc in tool_calls:
        segs.append((f'<invoke name="{tc.name}">', False))
        for key, value in tc.arguments.items():
            segs.append(
                (
                    f'<parameter name="{key}">'
                    f"{_minimax_m2_arg_value(value)}"
                    "</parameter>",
                    False,
                )
            )
        segs.append(("</invoke>", False))
    segs.append(("</minimax:tool_call>", True))
    return segs


def _minimax_m2_segments(scenario: Scenario) -> list[tuple[str, bool]]:
    segs: list[tuple[str, bool]] = []
    if scenario.reasoning is not None:
        segs.append((scenario.reasoning, False))
    if scenario.content is not None or scenario.tool_calls is not None:
        segs.append(("</think>", True))
    if scenario.tool_calls is not None and not scenario.tool_calls:
        segs.append(("<minimax:tool_call>", True))
        segs.append(("</minimax:tool_call>", True))
    if scenario.content is not None:
        segs.append((scenario.content, False))
    if scenario.tool_calls:
        segs.extend(_minimax_m2_tool_segments(scenario.tool_calls))
    return segs


def _build_minimax_m2(scenario: Scenario, validate: bool = True) -> Sample:
    expected_reasoning: str | None
    if scenario.reasoning is not None:
        expected_reasoning = scenario.reasoning.rstrip()
    else:
        expected_reasoning = ""

    sample = _make_sample(
        sample_id=f"minimax_m2-{scenario.id}",
        description=scenario.description,
        vocab=_MINIMAX_M2_VOCAB,
        segments=_minimax_m2_segments(scenario),
        expected_reasoning=expected_reasoning,
        expected_content=_qwen3_expected_content(scenario),
        expected_tool_calls=_expected_tc(scenario),
        tools=_expected_tools(scenario),
    )
    if validate:
        _validate_sample(sample, MinimaxM2Parser)
    return sample


# ── Gemma4 (channel reasoning, custom arg format) ────────────────────

_GEMMA4_VOCAB: dict[str, int] = {
    "<|channel>": 50,
    "<channel|>": 51,
    "<|tool_call>": 48,
    "<tool_call|>": 49,
    '<|"|>': 52,
    "<|turn>": 53,
    "<|tool_response>": 54,
}
_GEMMA4_THOUGHT_PREFIX = "thought\n"
_GEMMA4_QUOTE = '<|"|>'


def _gemma4_value_segments(value: Any) -> list[tuple[str, bool]]:
    """Render a value in Gemma4 arg format as segments."""
    if isinstance(value, str):
        return [(_GEMMA4_QUOTE, True), (value, False), (_GEMMA4_QUOTE, True)]
    if isinstance(value, bool):
        return [("true" if value else "false", False)]
    if isinstance(value, (int, float)):
        return [(str(value), False)]
    if isinstance(value, dict):
        segs: list[tuple[str, bool]] = [("{", False)]
        for i, (k, v) in enumerate(value.items()):
            if i > 0:
                segs.append((",", False))
            segs.append((f"{k}:", False))
            segs.extend(_gemma4_value_segments(v))
        segs.append(("}", False))
        return segs
    if isinstance(value, list):
        segs = [("[", False)]
        for i, item in enumerate(value):
            if i > 0:
                segs.append((",", False))
            segs.extend(_gemma4_value_segments(item))
        segs.append(("]", False))
        return segs
    return [(json.dumps(value, ensure_ascii=False), False)]


def _gemma4_tool_segments(tc: ToolCallSpec) -> list[tuple[str, bool]]:
    segs: list[tuple[str, bool]] = [
        ("<|tool_call>", True),
        (f"call:{tc.name}", False),
        ("{", False),
    ]
    for i, (key, value) in enumerate(tc.arguments.items()):
        if i > 0:
            segs.append((",", False))
        segs.append((f"{key}:", False))
        segs.extend(_gemma4_value_segments(value))
    segs.append(("}", False))
    segs.append(("<tool_call|>", True))
    return segs


def _gemma4_segments(scenario: Scenario) -> list[tuple[str, bool]]:
    segs: list[tuple[str, bool]] = []
    if scenario.reasoning is not None:
        segs.append(("<|channel>", True))
        segs.append((_GEMMA4_THOUGHT_PREFIX, False))
        segs.append((scenario.reasoning, False))
        segs.append(("<channel|>", True))
    if scenario.tool_calls is not None and not scenario.tool_calls:
        segs.append(("<|tool_call>", True))
        segs.append(("<tool_call|>", True))
    if scenario.content is not None:
        segs.append((scenario.content, False))
    if scenario.tool_calls:
        for tc in scenario.tool_calls:
            segs.extend(_gemma4_tool_segments(tc))
    return segs


def _build_gemma4(scenario: Scenario, validate: bool = True) -> Sample:
    prompt_token_ids = None
    if scenario.after_tool_response:
        prompt_token_ids = [_GEMMA4_VOCAB["<|tool_response>"]]
    sample = _make_sample(
        sample_id=f"gemma4-{scenario.id}",
        description=scenario.description,
        vocab=_GEMMA4_VOCAB,
        segments=_gemma4_segments(scenario),
        expected_reasoning=scenario.reasoning,
        expected_content=_qwen3_expected_content(scenario),
        expected_tool_calls=_expected_tc(scenario),
        tools=_expected_tools(scenario),
        prompt_token_ids=prompt_token_ids,
    )
    if validate:
        _validate_sample(sample, Gemma4Parser)
    return sample


def _build_nemotron_v3(scenario: Scenario, validate: bool = True) -> Sample:
    return _build_qwen3(
        scenario,
        name="nemotron_v3",
        parser_cls=NemotronV3Parser,
        strip_trailing_ws=True,
        validate=validate,
    )


# ── Seed-OSS (Qwen3 XML grammar with Seed wrapper tokens) ────────────

_SEED_OSS_VOCAB: dict[str, int] = {
    "<seed:think>": 50,
    "</seed:think>": 51,
    "<seed:tool_call>": 60,
    "</seed:tool_call>": 61,
}


def _seed_oss_tool_segments(tc: ToolCallSpec) -> list[tuple[str, bool]]:
    parts = [f"\n<function={tc.name}>"]
    for key, value in tc.arguments.items():
        parts.append(f"\n<parameter={key}>{_qwen3_arg_value(value)}</parameter>")
    parts.append("\n</function>\n")
    return [
        ("<seed:tool_call>", True),
        ("".join(parts), False),
        ("</seed:tool_call>", True),
    ]


def _seed_oss_segments(scenario: Scenario) -> list[tuple[str, bool]]:
    segs: list[tuple[str, bool]] = []
    if scenario.reasoning is not None:
        segs.append((scenario.reasoning, False))
    if scenario.content is not None or scenario.tool_calls is not None:
        segs.append(("</seed:think>", True))
    if scenario.tool_calls is not None and not scenario.tool_calls:
        segs.append(("<seed:tool_call>", True))
        segs.append(("</seed:tool_call>", True))
    if scenario.content is not None:
        segs.append((scenario.content, False))
    if scenario.tool_calls:
        for tc in scenario.tool_calls:
            segs.extend(_seed_oss_tool_segments(tc))
    return segs


def _build_seed_oss(scenario: Scenario, validate: bool = True) -> Sample:
    sample = _make_sample(
        sample_id=f"seed_oss-{scenario.id}",
        description=scenario.description,
        vocab=_SEED_OSS_VOCAB,
        segments=_seed_oss_segments(scenario),
        expected_reasoning=scenario.reasoning if scenario.reasoning is not None else "",
        expected_content=_qwen3_expected_content(scenario),
        expected_tool_calls=_expected_tc(scenario),
        tools=_expected_tools(scenario),
    )
    if validate:
        _validate_sample(sample, SeedOssParser)
    return sample


# ── GLM-4.7 MoE (XML tool format, starts in REASONING) ──────────────

_GLM47_MOE_VOCAB: dict[str, int] = {
    "<think>": 50,
    "</think>": 51,
    "<tool_call>": 60,
    "</tool_call>": 61,
    "<arg_key>": 62,
    "</arg_key>": 63,
    "<arg_value>": 64,
    "</arg_value>": 65,
}


def _glm47_moe_arg_value(value: Any) -> str:
    if isinstance(value, bool):
        return "true" if value else "false"
    if isinstance(value, (int, float)):
        return str(value)
    if isinstance(value, str):
        return value
    return json.dumps(value, ensure_ascii=False)


def _glm47_moe_tool_segments(tc: ToolCallSpec) -> list[tuple[str, bool]]:
    segs: list[tuple[str, bool]] = [
        ("<tool_call>", True),
        (tc.name, False),
    ]
    for key, value in tc.arguments.items():
        segs.extend(
            [
                ("<arg_key>", True),
                (key, False),
                ("</arg_key>", True),
                ("<arg_value>", True),
                (_glm47_moe_arg_value(value), False),
                ("</arg_value>", True),
            ]
        )
    segs.append(("</tool_call>", True))
    return segs


def _glm47_moe_segments(scenario: Scenario) -> list[tuple[str, bool]]:
    segs: list[tuple[str, bool]] = []
    if scenario.reasoning is not None:
        segs.append((scenario.reasoning, False))
    if scenario.content is not None or scenario.tool_calls:
        segs.append(("</think>", True))
    if scenario.content is not None:
        segs.append((scenario.content, False))
    if scenario.tool_calls:
        for tc in scenario.tool_calls:
            segs.extend(_glm47_moe_tool_segments(tc))
    return segs


def _build_glm47_moe(scenario: Scenario, validate: bool = True) -> Sample:
    sample = _make_sample(
        sample_id=f"glm47_moe-{scenario.id}",
        description=scenario.description,
        vocab=_GLM47_MOE_VOCAB,
        segments=_glm47_moe_segments(scenario),
        expected_reasoning=scenario.reasoning if scenario.reasoning is not None else "",
        expected_content=_qwen3_expected_content(scenario),
        expected_tool_calls=_expected_tc(scenario),
        tools=_expected_tools(scenario),
    )
    if validate:
        _validate_sample(sample, Glm47MoeParser)
    return sample


# ── Registry and public API ──────────────────────────────────────────

_BUILDERS: dict[str, Any] = {
    "qwen3": _build_qwen3,
    "gemma4": _build_gemma4,
    "minimax_m2": _build_minimax_m2,
    "nemotron_v3": _build_nemotron_v3,
    "seed_oss": _build_seed_oss,
    "glm47_moe": _build_glm47_moe,
}


@functools.cache
def build_samples(model: str) -> tuple[Sample, ...]:
    """Build all scenario samples for a model, self-validated."""
    builder = _BUILDERS[model]
    return tuple(builder(s) for s in SCENARIOS)


def build_sample(model: str, scenario: Scenario) -> Sample:
    """Build a single sample for one model + scenario."""
    return _BUILDERS[model](scenario)


def build_scaling_sample(
    model: str, token_count: int, validate: bool = False
) -> Sample:
    """Build a sample with approximately *token_count* tokens."""
    sentence = "The quick brown fox jumps over the lazy dog. "
    text = sentence * (token_count // 10 + 1)
    scenario = Scenario(
        id=f"scaling-{token_count}",
        description=f"Scaling test with ~{token_count} tokens",
        reasoning=text,
        tool_calls=[_READ_TOOL],
    )
    return _BUILDERS[model](scenario, validate=validate)
