# Copyright (c) ModelScope Contributors. All rights reserved.
# Multi-turn Rollout Schedulers for GRPO training.
import asyncio
import json
from abc import ABC
from copy import deepcopy
from typing import TYPE_CHECKING, Any, Dict, List, Optional, Union

from swift.infer_engine.protocol import (ChatCompletionResponse, ChatCompletionResponseChoice, RequestConfig,
                                         RolloutInferRequest, RolloutOutput)
from swift.template import Messages
from swift.utils import remove_response
from .gym_env import Env, envs

if TYPE_CHECKING:
    # Imported only for type hints; importing it at runtime pulls in vllm, which would make
    # `swift.rollout` (and thus GRPO trainer init) hard-require vllm even when use_vllm=False.
    from swift.infer_engine import GRPOVllmEngine


class RolloutScheduler(ABC):
    # Single Turn Rollout Scheduler
    def __init__(self,
                 infer_engine: Optional['GRPOVllmEngine'] = None,
                 max_turns: Optional[int] = None,
                 *args,
                 **kwargs):
        self.infer_engine = infer_engine
        # Tokenizer can be passed explicitly (e.g., in colocate mode where infer_engine may be None)
        self._tokenizer = kwargs.get('tokenizer', None)
        self.max_turns = max_turns

    # ------------------------------------------------------------------
    # Universal hooks — called by BOTH ``run()`` (server mode) and
    # ``run_multi_turn()`` (colocate mode). Override these to inject
    # environment lifecycle logic (e.g. gym env.reset / env.step) without
    # overriding the full ``run()`` method.
    #
    # Hooks are async so that gym environments (whose reset/step are async)
    # can be awaited directly. In server mode ``run()`` awaits them natively;
    # in colocate mode ``run_multi_turn()`` drives them via a dedicated loop.
    # ------------------------------------------------------------------
    async def on_trajectory_start(self, requests: List['RolloutInferRequest']) -> None:
        """Called before the first inference turn to initialize per-trajectory state.

        Mutate ``requests`` in place (e.g. inject env initial observation).
        Default: no-op.
        """
        pass

    async def on_turn_end(self, infer_request: 'RolloutInferRequest', response_choice: 'ChatCompletionResponseChoice',
                          current_turn: int) -> Dict[str, Any]:
        """Called after assistant message is appended, before ``check_finished``.

        Use this to advance environment state (e.g. ``env.step``) and surface
        per-turn metadata.

        Returns:
            Dict with optional keys:
            - 'done' (bool): if present, overrides ``check_finished`` result
            - 'rollout_infos' (dict): merged into the trajectory's accumulated infos
        Default: empty dict (no-op).
        """
        return {}

    async def async_infer(self,
                          infer_requests: List[Union['RolloutInferRequest', Dict[str, Any]]],
                          request_config: 'RequestConfig',
                          *,
                          use_tqdm: Optional[bool] = None,
                          **kwargs) -> List['RolloutOutput']:
        """
        Perform asynchronous batched inference for multiple rollout requests.

        This method serves as the main entry point for multi-round training inference.
        It executes the `run` method for each inference request concurrently and
        aggregates the results into a single flattened list.

        Each inference request can be either a `RolloutInferRequest` instance or a
        dictionary that can be converted into one. The results from all requests are
        collected asynchronously using the underlying inference engine.

        Args:
            infer_requests (List[Union[RolloutInferRequest, Dict[str, Any]]]):
                A list of inference requests. Each request can be either:
                    - A `RolloutInferRequest` object.
                    - A dictionary containing the fields required to initialize a
                    `RolloutInferRequest`.
            request_config (RequestConfig):
                Configuration object specifying inference settings. Must satisfy
                `request_config.n == 1`, as only single-response generation is supported.
            use_tqdm (Optional[bool], optional):
                Whether to display a progress bar during batch inference.
                If `None`, it defaults to `True` when there are multiple requests,
                otherwise `False`.
            **kwargs:
                Additional arguments forwarded to the underlying `run` method.

        Returns:
            List[RolloutOutput]:
                A list of RolloutOutput objects corresponding to the provided inference requests.

        Raises:
            AssertionError:
                If `request_config.n` is not equal to `1`.

        Notes:
            - Internally, this method converts dict-based requests into
            `RolloutInferRequest` instances.
            - Uses `infer_engine._batch_infer_stream` to perform concurrent execution.
            - The returned list is guaranteed to be flattened, even if individual
            tasks return lists of responses.
        """

        assert request_config.n == 1

        async def _infer_async_single(infer_request: Union['RolloutInferRequest', Dict[str, Any]],
                                      request_config: 'RequestConfig', **kwargs):
            if isinstance(infer_request, Dict):
                infer_request = RolloutInferRequest(**infer_request)

            return await self.run(infer_request, request_config, **kwargs)

        tasks = [_infer_async_single(infer_request, request_config, **kwargs) for infer_request in infer_requests]
        if use_tqdm is None:
            use_tqdm = len(infer_requests) > 1
        # Execute all tasks and flatten the results
        results = await self.infer_engine._batch_infer_stream(tasks, request_config.stream, use_tqdm, None)
        # Flatten the results since each task may return a list
        flattened_results = []
        for result in results:
            if isinstance(result, list):
                flattened_results.extend(result)
            else:
                flattened_results.append(result)
        return flattened_results

    async def run(self, infer_request: 'RolloutInferRequest', request_config: 'RequestConfig',
                  **kwargs) -> 'RolloutOutput':
        response: 'ChatCompletionResponse' = await self.infer_engine.infer_async(infer_request, request_config,
                                                                                 **kwargs)
        response_token_ids = response.choices[0].token_ids
        response_loss_mask = [1] * len(response_token_ids)
        return RolloutOutput(
            response=response,
            messages=infer_request.messages,
            response_token_ids=[response_token_ids],
            response_loss_mask=[response_loss_mask],
            rollout_infos={'num_turns': 1})

    def __getattr__(self, key: str):
        try:
            return object.__getattribute__(self, key)
        except AttributeError:
            pass

        try:
            infer_engine = object.__getattribute__(self, 'infer_engine')
            if hasattr(infer_engine, key):
                return getattr(infer_engine, key)
            if hasattr(infer_engine.engine, key):
                return getattr(infer_engine.engine, key)

        except AttributeError:
            raise AttributeError(f'{type(self).__name__} object has no attribute {key}')

    @property
    def engine(self):
        return self.infer_engine

    @property
    def tokenizer(self):
        """Get tokenizer, prioritizing explicitly passed tokenizer over infer_engine's tokenizer."""
        if self._tokenizer is not None:
            return self._tokenizer
        if self.infer_engine is not None:
            return self.infer_engine.tokenizer
        return None


class MultiTurnScheduler(RolloutScheduler, ABC):
    """
    Abstract base class for multi-turn rollout scheduling.

    Provides default implementation for multi-turn conversation management with two customization approaches:

    1. FULL CUSTOMIZATION:
       Override the `run()` method to implement completely custom multi-turn logic.
       - Gives full control over the rollout process
       - Must handle all turn management and termination logic

    2. PARTIAL CUSTOMIZATION:
       Implement the required `step()` method and optionally override `check_finished()`
       - Uses MultiTurnScheduler's run() method infrastructure
       - Only need to implement turn transition logic in step()
       - Optionally customize termination conditions

    Note: You must implement at least one of these approaches in your subclass.

    Options:
        - If each round's response_token_ids are included in the RolloutOutput,
          the Trainer can skip encoding the completion text into token_ids when calculating loss.
          This avoids potential training inconsistencies due to asymmetric encode/decode behavior.
          See: https://github.com/0russwest0/Agent-R1/issues/30#issuecomment-2826155367

        - If both response_token_ids and response_loss_mask are returned in the RolloutOutput,
          you can manually control the loss mask for each token.
          The Trainer will use the provided loss_mask values directly when computing the loss.
          Note: Returning response_loss_mask requires that response_token_ids are also returned,
          as the two must be aligned in length for correct loss computation.

        You can refer to MathTipsScheduler as an example of how to use response_token_ids and response_loss_mask.

    Loss mask configuration:
        During rollout, some parts of the completion (e.g., environment observations embedded in completion)
        may need to be masked out from loss computation.
        There are two supported strategies:

        1. Use the built-in `loss_scale` parameter in ms-swift and do not return response token ids.
        2. Return response_token_ids along with a corresponding response_loss_mask (of equal length) to indicate the loss mask for each token. # noqa
    """

    async def run(self, infer_request: 'RolloutInferRequest', request_config: 'RequestConfig',
                  **kwargs) -> Union['RolloutOutput', List['RolloutOutput']]:
        """Execute multi-turn conversation rollout with built-in turn management logic.

        This implements the default multi-turn interaction flow that can be overridden
        to customize conversation handling behavior. The default logic provides:

        1. Automatic conversation turn management and stopping conditions
        2. Seamless message accumulation across multiple turns
        3. Response token tracking and loss mask management
        4. Configurable early stopping mechanisms

        Args:
            infer_request: The initial inference request containing conversation messages
            request_config: Configuration parameters for the inference request
            **kwargs: Additional inference parameters passed to the engine

        Returns:
            RolloutOutput containing the complete conversation history and metadata,
            or a list of outputs for batched requests

        Customization Approaches:
            - Override check_finished() to implement custom stopping criteria
            - Override step() to customize turn-to-turn transition logic
            - Override this entire run() method for completely custom multi-turn behavior

        Important Notes:
            - Method overriding is only supported when using server mode (swift rollout)
              with vllm_use_async_engine=True
            - Custom implementations must maintain async/await compatibility
            - Ensure proper handling of conversation state across turns

        Example:
            class CustomScheduler(MultiTurnScheduler):
                async def run(self, infer_request, request_config, **kwargs):
                    # Implement custom multi-turn conversation logic
                    # Must return RolloutOutput or List[RolloutOutput]
                    ...
        """
        current_request = infer_request
        await self.on_trajectory_start([current_request])
        current_turn = 1
        rollout_infos = {}
        total_response_ids = []
        total_response_loss_mask = []
        total_rollout_logprobs = []
        while True:
            messages = current_request.messages
            if current_turn == 1:
                # If it's the first turn, remove the response
                # Keep the original logic, but I think this step is unnecessary here.
                remove_response(messages)

            # Get model response
            response: 'ChatCompletionResponse' = await self.infer_engine.infer_async(
                current_request, request_config, **kwargs)
            response_choice: 'ChatCompletionResponseChoice' = response.choices[0]

            if current_turn > 1 and not messages[-1]['content']:
                # The dummy assistant message was intentionally kept during `infer_async`
                # to ensure correct history processing by the template.
                # It is now removed before appending the new completion.
                # otherwise, a syntax error would occur when executing messages[-1]['content'] += completion.
                remove_response(messages)

            # Update conversation history
            completion = response_choice.message.content
            is_continuation = False
            if messages[-1]['role'] == 'assistant':
                messages[-1]['content'] += completion
                is_continuation = True
            else:
                messages.append({'role': 'assistant', 'content': completion})

            # Check stopping conditions
            turn_result = await self.on_turn_end(current_request, response_choice, current_turn)
            if turn_result.get('rollout_infos'):
                rollout_infos.update(turn_result['rollout_infos'])
            should_stop = self.check_finished(current_request, response_choice, current_turn)
            if 'done' in turn_result:
                should_stop = turn_result['done']

            # double-check if user forget to judge the max_turns
            if self.max_turns:
                should_stop = should_stop or (current_turn >= self.max_turns)

            if should_stop:
                # Collect final turn's data
                current_logprobs = self._extract_logprobs_from_choice(response_choice)
                final_token_ids = response_choice.token_ids

                if is_continuation and total_response_ids:
                    # For continuation, extend the last turn's data
                    total_response_ids[-1].extend(final_token_ids)
                    if total_response_loss_mask:
                        total_response_loss_mask[-1].extend([1] * len(final_token_ids))
                    if total_rollout_logprobs and current_logprobs:
                        total_rollout_logprobs[-1].extend(current_logprobs)
                elif not total_response_ids:
                    # First turn stopped immediately - need to initialize with final response data
                    if final_token_ids:
                        total_response_ids = [list(final_token_ids)]
                        total_response_loss_mask = [[1] * len(final_token_ids)]
                    if current_logprobs:
                        total_rollout_logprobs = [current_logprobs]

                # Validate rollout_logprobs completeness: if logprobs are incomplete (missing for some turns),
                # clear them to disable rollout importance sampling correction (which requires complete logprobs)
                # Note: rollout_logprobs should match the number of loss_mask=1 tokens, not total response tokens
                # because completion_mask in grpo_trainer is based on labels != -100, which corresponds to loss_mask=1
                final_rollout_logprobs = total_rollout_logprobs
                if total_rollout_logprobs:
                    total_logprob_count = sum(len(turn_lps) for turn_lps in total_rollout_logprobs)
                    if total_response_loss_mask:
                        # Check if the number of logprobs matches the number of loss_mask=1 tokens
                        total_loss_mask_1_count = sum(sum(mask) for mask in total_response_loss_mask)
                        if total_loss_mask_1_count != total_logprob_count:
                            # Incomplete logprobs, clear them
                            final_rollout_logprobs = []
                    else:
                        if total_response_ids:
                            total_response_id_count = sum(len(turn_ids) for turn_ids in total_response_ids)
                            if total_response_id_count != total_logprob_count:
                                final_rollout_logprobs = []
                        else:
                            final_rollout_logprobs = []

                return RolloutOutput(
                    response=response,
                    messages=messages,
                    response_token_ids=total_response_ids,
                    response_loss_mask=total_response_loss_mask,
                    rollout_infos={
                        **rollout_infos, 'num_turns': current_turn
                    },
                    rollout_logprobs=final_rollout_logprobs,
                )

            # Prepare next turn
            ret = self.step(current_request, response_choice, current_turn)
            current_request: 'RolloutInferRequest' = ret['infer_request']

            # Track response tokens and masks
            return_token_id = False
            if 'response_token_ids' in ret:
                if is_continuation and total_response_ids:
                    total_response_ids[-1].extend(ret['response_token_ids'])
                else:
                    total_response_ids.append(ret['response_token_ids'])
                return_token_id = True

            if 'response_loss_mask' in ret:
                assert return_token_id, 'You must return response_token_ids if you want to return response_loss_mask'
                assert len(ret['response_loss_mask']) == len(ret['response_token_ids']), \
                    'response_loss_mask must have the same length as response_token_ids'
                if is_continuation and total_response_loss_mask:
                    total_response_loss_mask[-1].extend(ret['response_loss_mask'])
                else:
                    total_response_loss_mask.append(ret['response_loss_mask'])

            if 'rollout_infos' in ret:
                # Always overwrite the rollout info for this step.
                # If you need to keep all step-wise details, switch to append or merge instead.
                rollout_infos.update(ret['rollout_infos'])

            # Track rollout_logprobs for rollout importance sampling correction
            # Prefer step's returned logprobs (which may be modified/truncated) over raw response_choice logprobs
            if 'rollout_logprobs' in ret and ret['rollout_logprobs']:
                current_logprobs = ret['rollout_logprobs']
            else:
                current_logprobs = self._extract_logprobs_from_choice(response_choice)
            if current_logprobs:
                if is_continuation and total_rollout_logprobs:
                    total_rollout_logprobs[-1].extend(current_logprobs)
                else:
                    total_rollout_logprobs.append(current_logprobs)

            current_turn += 1

    def step(self, infer_request: 'RolloutInferRequest', response_choice: 'ChatCompletionResponseChoice',
             current_turn: int) -> Dict:
        """
        Handles transition between conversation turns.

        Args:
            infer_request: Current inference request
            response_choice: Response from current turn
            current_turn: Current turn number

        Returns:
            Dict[str, Any]: A dictionary containing inference results with the following structure:
                - infer_request (required): Main inference request object
                - response_token_ids (Optional[List[int]]): Token IDs of response for current rollout turn
                - response_loss_mask (Optional[List[int]]): Loss mask for response tokens (same length as response_token_ids) # noqa
                - rollout_logprobs (Optional[List[float]]): Log probabilities for response tokens.
                    If not provided, will be extracted from response_choice.logprobs as fallback.
                    Useful when modifying response content (e.g., adding prompts) to avoid logprob misalignment.
                - rollout_infos (Optional[Dict[str, Any]]): Additional metadata (must be serializable)

        """
        raise NotImplementedError(
            'Please implement the `step` method in your MultiTurnScheduler subclass, or override the `run` method.')

    def check_finished(self, infer_request: 'RolloutInferRequest', response_choice: 'ChatCompletionResponseChoice',
                       current_turn: int) -> bool:
        """
        Default termination logic for checking if a multi-turn rollout should end.

        This method is invoked by:
        - The base class MultiTurnScheduler.run() method, OR
        - Custom run() methods when explicitly called

        Note: This is the default implementation that can be overridden by subclasses for custom termination logic.

        Termination Conditions:
        1. When response hits length limit (finish_reason == 'length')
        2. When conversation reaches max_turns (if max_turns is set)

        Args:
            infer_request: The inference request object
            response_choice: Contains generation results including finish_reason
            current_turn: Current conversation turn count

        Returns:
            bool: True to terminate conversation, False to continue
        """
        if response_choice.finish_reason == 'length':
            return True
        if self.max_turns and current_turn >= self.max_turns:
            return True
        return False

    @staticmethod
    def _extract_logprobs_from_choice(response_choice: 'ChatCompletionResponseChoice') -> List[float]:
        """Extract logprobs list from response choice for rollout importance sampling.

        Args:
            response_choice: The response choice containing logprobs

        Returns:
            List of logprob values, or empty list if not available
        """
        if response_choice.logprobs is None:
            return []
        if 'content' in response_choice.logprobs:
            return [item['logprob'] for item in response_choice.logprobs['content']]
        return []


class ThinkingModelTipsScheduler(MultiTurnScheduler):
    """
    Scheduler for multi-turn reasoning with Thinking class models.

    Key Features:
    1. Parses both "think" and "answer" content from each assistant response.
    2. For each round, only the "think" content from the last round is retained in the message history.
    3. Each round's conversation history is processed independently.
    4. Returns a list of RolloutOutput objects, one for each round.
    5. Please set `--loss_scale last_round` for training last round response.

    The scheduler will automatically inject a tip prompt if the answer is incorrect, encouraging the model to recheck its reasoning. # noqa
    """

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        acc_func = kwargs.get('acc_function', None)
        if acc_func is None:
            from swift.rewards.orm import MathAccuracy
            acc_func = MathAccuracy()
        self.acc_func = acc_func
        self.tips_prompt = 'The answer is not correct, It seems You made a mistake, you need to recheck very carefully.'

    async def run(self, infer_request: 'RolloutInferRequest', request_config: 'RequestConfig',
                  **kwargs) -> List['RolloutOutput']:
        """
        Execute multi-turn inference for Thinking models.

        Args:
            infer_request (RolloutInferRequest): The initial inference request containing the conversation history.
            request_config (RequestConfig): Configuration for the inference request.
            **kwargs: Additional arguments for the inference engine.

        Returns:
            List[RolloutOutput]: A list of RolloutOutput objects, one for each reasoning round.
        """
        current_request = infer_request
        current_turn = 1
        rollout_outputs = []

        while True:
            messages = current_request.messages
            # Obtain model response for the current turn
            response: 'ChatCompletionResponse' = await self.infer_engine.infer_async(
                current_request, request_config, **kwargs)
            response_choice: 'ChatCompletionResponseChoice' = response.choices[0]
            completion = response_choice.message.content

            # Append the assistant's response to the message history
            messages.append({'role': 'assistant', 'content': completion})

            # Construct the message history for this round, keeping only the last "think" content
            messages_with_last_think = self._build_messages(messages)

            # Create a RolloutOutput for the current round
            round_output = RolloutOutput(
                response=response,
                messages=messages_with_last_think,
                response_token_ids=response_choice.token_ids,
                rollout_infos={'num_turns': current_turn})
            # Store the output for this round
            rollout_outputs.append(round_output)

            # Determine whether to stop the multi-turn reasoning
            should_stop = self.check_finished(current_request, response_choice, current_turn)

            if should_stop:
                break

            # Prepare for the next turn by updating the inference request
            ret = self.step(current_request, response_choice, current_turn)
            current_request: 'RolloutInferRequest' = ret['infer_request']
            current_turn += 1

        return rollout_outputs

    def check_finished(self, infer_request: 'RolloutInferRequest', response_choice: 'ChatCompletionResponseChoice',
                       current_turn: int) -> bool:

        last_query = infer_request.messages[-2]['content']
        # tips once
        if self.tips_prompt in last_query:
            return True

        completion = response_choice.message.content
        solution = infer_request.data_dict['solution']
        acc = self.acc_func([completion], [solution])[0]
        if acc == 1:
            return True

        return super().check_finished(infer_request, response_choice, current_turn)

    def step(self, infer_request: 'RolloutInferRequest', response_choice: 'ChatCompletionResponseChoice',
             current_turn: int) -> Dict:
        infer_request.messages.append({'role': 'user', 'content': self.tips_prompt})

        return {'infer_request': infer_request}

    def _is_thinking_template(self) -> bool:
        if not hasattr(self.infer_engine, 'template'):
            return False

        template = self.infer_engine.template
        return template.template_meta.is_thinking

    def _build_messages(self, original_messages: Messages) -> Messages:
        """
        Build history for a specific round, keeping only the think content from the last round.

        Args:
            original_messages: Original conversation messages

        Returns:
            Messages: History for this specific round
        """
        from copy import deepcopy

        # If this is a thinking template, use the template's method to prepare messages
        if self._is_thinking_template():
            # Create a mock inputs object to use the template's _swift_prepare_inputs method
            class MockInputs:

                def __init__(self, messages):
                    self.messages = deepcopy(messages)

            mock_inputs = MockInputs(original_messages)

            # Set up the template for inference mode
            template = self.infer_engine.template
            # _swift_prepare_inputs will remove historical thinking content when in train mode, patch the mode here
            original_mode = template.mode
            template.mode = 'train'
            # Use the template's method to prepare messages
            template._swift_prepare_inputs(mock_inputs)
            # Restore original mode
            template.mode = original_mode

            return mock_inputs.messages
        else:
            # Fallback to manual processing for non-thinking templates
            round_messages = []

            # Process messages in original order
            for i, msg in enumerate(original_messages):
                if msg['role'] == 'assistant' and isinstance(msg['content'], str) and i != len(original_messages) - 1:
                    # For assistant messages
                    assistant_no_think = msg['content'].split('</think>')[-1].strip()
                    round_messages.append(assistant_no_think)
                else:
                    round_messages.append(deepcopy(msg))

            return round_messages


class MathTipsScheduler(MultiTurnScheduler):
    tips_prompt = 'But wait... It seems I made a mistake,'

    def __init__(self, *args, **kwargs):
        from swift.rewards.orm import MathAccuracy
        super().__init__(*args, **kwargs)
        self.acc_func = kwargs.get('acc_function', MathAccuracy())
        # Cache the tokenized tips_prompt length for loss mask computation
        self._tips_token_ids = None

    def _get_tips_token_ids(self, tokenizer) -> List[int]:
        """Get tokenized tips_prompt (cached for efficiency)."""
        if self._tips_token_ids is None:
            # Tokenize without special tokens to get the raw token ids
            self._tips_token_ids = tokenizer.encode(self.tips_prompt, add_special_tokens=False)
        return self._tips_token_ids

    def check_finished(self, infer_request: 'RolloutInferRequest', response_choice: 'ChatCompletionResponseChoice',
                       current_turn: int) -> bool:
        last_completion = infer_request.messages[-1]['content']
        # we only give tips once
        if self.tips_prompt in last_completion:
            return True
        solution = infer_request.data_dict['solution']

        acc = self.acc_func([last_completion], [solution])[0]
        if acc == 1:
            return True

        return super().check_finished(infer_request, response_choice, current_turn)

    def step(self, infer_request: 'RolloutInferRequest', response_choice: 'ChatCompletionResponseChoice',
             current_turn: int) -> Dict:
        completion = response_choice.message.content
        response_token_ids = list(response_choice.token_ids) if response_choice.token_ids else []

        # Extract logprobs from response_choice before any truncation
        rollout_logprobs = self._extract_logprobs_from_choice(response_choice)

        # Truncate completion at <answer> or </think> tags
        truncate_idx = len(completion)
        if '<answer>' in completion:
            truncate_idx = min(truncate_idx, completion.index('<answer>'))
        if '</think>' in completion:
            truncate_idx = min(truncate_idx, completion.index('</think>'))

        if truncate_idx < len(completion):
            # Need to truncate token_ids and logprobs as well
            truncated_completion = completion[:truncate_idx]
            if response_token_ids and self.tokenizer is not None:
                # Find the token index corresponding to the truncation point
                # by decoding progressively until we reach or exceed the truncation point
                token_truncate_idx = len(response_token_ids)
                for i in range(1, len(response_token_ids) + 1):
                    decoded = self.tokenizer.decode(response_token_ids[:i], skip_special_tokens=False)
                    if len(decoded) >= truncate_idx:
                        token_truncate_idx = i
                        break
                response_token_ids = response_token_ids[:token_truncate_idx]
                # Truncate logprobs to match
                if rollout_logprobs:
                    rollout_logprobs = rollout_logprobs[:token_truncate_idx]
            completion = truncated_completion

        # Add tips_prompt
        completion += self.tips_prompt

        # Compute loss_mask for tips tokens
        # Note: rollout_logprobs should NOT include tips tokens because:
        # 1. Tips tokens have loss_mask=0, so their labels will be -100
        # 2. completion_mask = (labels != -100), so tips tokens won't be in completion_mask
        # 3. rollout_logprobs must align with completion_mask, not response_token_ids
        if response_token_ids and self.tokenizer is not None:
            tips_token_ids = self._get_tips_token_ids(self.tokenizer)
            # Loss mask: original tokens = 1, tips tokens = 0
            response_loss_mask = [1] * len(response_token_ids) + [0] * len(tips_token_ids)
            # Append tips token ids to response
            response_token_ids = response_token_ids + tips_token_ids
            # Do NOT extend rollout_logprobs for tips tokens - they are masked out in completion_mask
        else:
            response_loss_mask = []

        # Update messages
        if infer_request.messages[-1]['role'] == 'assistant':
            if not infer_request.messages[-1]['content']:
                # Multi-turn continuation: pop the dummy input we add in last turn
                infer_request.messages.pop(-1)
            infer_request.messages[-1]['content'] = completion
        else:
            infer_request.messages.append({'role': 'assistant', 'content': completion})

        result = {'infer_request': infer_request}
        if response_token_ids:
            result['response_token_ids'] = response_token_ids
            result['response_loss_mask'] = response_loss_mask
            if rollout_logprobs:
                result['rollout_logprobs'] = rollout_logprobs
        return result


class GYMScheduler(MultiTurnScheduler):
    """Gym environment-driven scheduler using universal hooks.

    Implements ``on_trajectory_start`` (env.reset) and ``on_turn_end`` (env.step)
    to integrate gym environments into the multi-turn protocol. Works in both
    server mode (``run()``) and colocate mode (``run_multi_turn()``).
    """

    def __init__(self, infer_engine: Optional['GRPOVllmEngine'] = None, max_turns: Optional[int] = None, **kwargs):
        super().__init__(infer_engine, max_turns, **kwargs)
        self.gym_env_name = kwargs.get('gym_env', None)
        # Per-trajectory state (keyed by uuid)
        self._envs: Dict[str, Env] = {}
        self._total_rewards: Dict[str, float] = {}
        self._step_rewards: Dict[str, List[float]] = {}
        self._pending_obs: Dict[str, Optional[str]] = {}

    async def _close_and_remove(self, uuid: str) -> None:
        """Close env for a given uuid and remove all associated state."""
        env = self._envs.pop(uuid, None)
        if env is not None:
            try:
                await env.close()
            except Exception:
                pass
        self._total_rewards.pop(uuid, None)
        self._step_rewards.pop(uuid, None)
        self._pending_obs.pop(uuid, None)

    # ------------------------------------------------------------------
    # Universal async hooks (called by both run() and run_multi_turn())
    # ------------------------------------------------------------------
    async def on_trajectory_start(self, requests: List['RolloutInferRequest']) -> None:
        """Create one env per request and seed messages with initial observation."""

        async def _init_single(req: 'RolloutInferRequest') -> None:
            uuid = req.uuid
            if uuid in self._envs:
                await self._close_and_remove(uuid)

            env_config = (req.data_dict or {}).get('env_config', {}) if hasattr(req, 'data_dict') else {}
            env = self._create_env(env_config)
            observation, info, system_message = await env.reset(req)

            messages: Messages = []
            if system_message:
                messages.append({'role': 'system', 'content': system_message})
            messages.append({'role': 'user', 'content': observation})
            req.messages = messages

            self._envs[uuid] = env
            self._total_rewards[uuid] = 0.0
            self._step_rewards[uuid] = []
            self._pending_obs[uuid] = None

        await asyncio.gather(*[_init_single(req) for req in requests])

    async def on_turn_end(self, infer_request: 'RolloutInferRequest', response_choice: 'ChatCompletionResponseChoice',
                          current_turn: int) -> Dict[str, Any]:
        """Advance the gym env, accumulate reward, and return done + rollout_infos."""
        uuid = infer_request.uuid
        env = self._envs.get(uuid)
        if env is None:
            return {'done': True, 'rollout_infos': {}}

        next_obs, reward, done, info = await env.step(deepcopy(infer_request.messages))
        self._total_rewards[uuid] = self._total_rewards.get(uuid, 0.0) + float(reward)
        self._step_rewards.setdefault(uuid, []).append(float(reward))

        self._pending_obs[uuid] = None if done else next_obs

        rollout_infos: Dict[str, Any] = {
            'total_reward': self._total_rewards[uuid],
            'step_rewards': list(self._step_rewards.get(uuid, [])),
            'gym_done': done,
        }
        if done:
            await self._close_and_remove(uuid)

        return {'done': done, 'rollout_infos': rollout_infos}

    # ------------------------------------------------------------------
    # Step hook (injects next observation for the next turn)
    # ------------------------------------------------------------------
    def step(self, infer_request: 'RolloutInferRequest', response_choice: 'ChatCompletionResponseChoice',
             current_turn: int) -> Dict[str, Any]:
        uuid = infer_request.uuid
        next_obs = self._pending_obs.get(uuid)
        if next_obs is not None:
            infer_request.messages.append({'role': 'user', 'content': next_obs})
            self._pending_obs[uuid] = None
        return {'infer_request': infer_request}

    # ------------------------------------------------------------------
    # Env helpers
    # ------------------------------------------------------------------
    def _create_env(self, env_config: Dict) -> Env:
        env_name = env_config.get('name', self.gym_env_name)
        if env_name not in envs:
            raise ValueError(f"Environment '{env_name}' not found. Available: {list(envs.keys())}")
        return envs[env_name](env_config)


class OpenEnvScheduler(GYMScheduler):
    """GYMScheduler specialised for OpenEnv environments.

    Unlike GYMScheduler which uses async ``Env`` instances, OpenEnvScheduler
    uses :class:`OpenEnvWrapper` whose ``reset()`` / ``step()`` / ``close()``
    are **synchronous** (blocking WebSocket I/O).  Subclasses that override
    ``on_trajectory_start`` / ``on_turn_end`` should wrap sync wrapper calls
    with ``asyncio.to_thread()`` to avoid blocking the event loop.

    Action parsing (LLM text → dict) and observation formatting (dict → str)
    are handled by overridable :meth:`parse_action` and :meth:`format_observation`
    methods, eliminating the need for ``openenv_*`` command-line parameters.

    All OpenEnv configuration (``base_url``, ``system_message``, ``reset_kwargs`` …)
    comes from the dataset's per-row ``env_config``.
    """

    def _create_env(self, env_config: Dict) -> Any:
        """Create an :class:`OpenEnvWrapper` (not an ``Env`` subclass)."""
        from .openenv_wrapper import OpenEnvWrapper
        return OpenEnvWrapper(env_config)

    async def _close_and_remove(self, uuid: str) -> None:
        """Close wrapper for a given uuid and remove all associated state.

        Wrapper.close() is synchronous; use ``asyncio.to_thread`` to avoid
        blocking the event loop.
        """
        import asyncio
        wrapper = self._envs.pop(uuid, None)
        if wrapper is not None:
            try:
                await asyncio.to_thread(wrapper.close)
            except Exception:
                pass
        self._total_rewards.pop(uuid, None)
        self._step_rewards.pop(uuid, None)
        self._pending_obs.pop(uuid, None)

    async def on_trajectory_start(self, requests: List['RolloutInferRequest']) -> None:
        """Create one wrapper per request, call ``reset()``, seed messages.

        Uses a semaphore to limit concurrent environment creations (default 4)
        to avoid overwhelming the OpenEnv server with simultaneous WebSocket connections.
        """
        semaphore = asyncio.Semaphore(getattr(self, 'max_concurrent_envs', 4))

        async def _init_single(req: 'RolloutInferRequest') -> None:
            async with semaphore:
                uuid = req.uuid
                if uuid in self._envs:
                    await self._close_and_remove(uuid)

                row_env_config = (req.data_dict or {}).get('env_config', {}) if hasattr(req, 'data_dict') else {}
                env_config = {**getattr(self, 'env_config_defaults', {}), **row_env_config}
                wrapper = self._create_env(env_config)

                obs, metadata = wrapper.reset()
                system_message = env_config.get('system_message', '')

                messages: Messages = []
                if system_message:
                    messages.append({'role': 'system', 'content': system_message})
                messages.append({'role': 'user', 'content': self.format_observation(obs)})
                req.messages = messages

                self._envs[uuid] = wrapper
                self._total_rewards[uuid] = 0.0
                self._step_rewards[uuid] = []
                self._pending_obs[uuid] = None

        await asyncio.gather(*[_init_single(req) for req in requests])

    async def on_turn_end(self, infer_request: 'RolloutInferRequest', response_choice: 'ChatCompletionResponseChoice',
                          current_turn: int) -> Dict[str, Any]:
        """Parse LLM response, call ``wrapper.step()``, accumulate reward."""
        uuid = infer_request.uuid
        wrapper = self._envs.get(uuid)
        if wrapper is None:
            return {'done': True, 'rollout_infos': {}}

        action_text = response_choice.message.content
        action_dict = self.parse_action(action_text)
        obs, reward, done, metadata = wrapper.step(action_dict)

        self._total_rewards[uuid] = self._total_rewards.get(uuid, 0.0) + float(reward)
        self._step_rewards.setdefault(uuid, []).append(float(reward))

        next_obs = None if done else self.format_observation(obs)
        self._pending_obs[uuid] = next_obs

        rollout_infos: Dict[str, Any] = {
            'total_reward': self._total_rewards[uuid],
            'step_rewards': list(self._step_rewards.get(uuid, [])),
            'gym_done': done,
        }
        if done:
            await self._close_and_remove(uuid)

        return {'done': done, 'rollout_infos': rollout_infos}

    def parse_action(self, text: str) -> Dict[str, Any]:
        """Parse LLM response text into an OpenEnv action dict.

        Default: try ``json.loads``, fall back to ``{"message": text}``.
        """
        text = text.strip()
        # Strip markdown code blocks (e.g. ```json ... ```)
        if text.startswith('```'):
            lines = text.splitlines()
            if len(lines) >= 2 and lines[0].startswith('```') and lines[-1].strip().startswith('```'):
                text = '\n'.join(lines[1:-1]).strip()
        try:
            parsed = json.loads(text)
            if isinstance(parsed, dict):
                return parsed
            return {'message': str(parsed)}
        except (json.JSONDecodeError, ValueError):
            return {'message': text}

    def format_observation(self, observation: Any) -> str:
        """Format OpenEnv observation into a string for the LLM.

        Default: ``json.dumps``.  Override in subclasses for environment-specific
        formatting (e.g. extract a ``"question"`` field).
        """
        try:
            return json.dumps(observation, ensure_ascii=False, default=str)
        except (TypeError, ValueError):
            return str(observation)


multi_turns = {
    'math_tip_trick': MathTipsScheduler,
    'gym_scheduler': GYMScheduler,
    'openenv_scheduler': OpenEnvScheduler,
    'thinking_tips_scheduler': ThinkingModelTipsScheduler,
}
