"""Sudoku scheduler for OpenEnv TextArena Sudoku environment.

Reference: TRL openenv_sudoku_grpo.ipynb
Key features:
1. Multiple reward functions (empty_cell, valid_move, repetition, progress, correct)
2. Hints system: parse board, provide guaranteed moves and candidates
3. Board state tracking with content diff for bounded context
"""
import asyncio
import re
from collections import defaultdict
from typing import Any, Dict, List, Optional, Union

from swift.rollout.multi_turn import OpenEnvScheduler, multi_turns

SUDOKU_SYSTEM_PROMPT = """You are an expert Sudoku player with deep knowledge of logical deduction strategies.

## GAME RULES
1. The puzzle is a 9x9 grid divided into nine 3x3 subgrids (boxes)
2. Some cells are pre-filled with numbers 1-9
3. Fill empty cells ('.') with numbers 1-9
4. Each row, column, and 3x3 box must contain 1-9 without repetition
5. Cannot overwrite pre-filled cells
6. Invalid moves result in penalties

## HOW TO PLAY
Output your move in this format: [row col number]
Example: [3 5 7] means place 7 at row 3, column 5.
You may reason before your move, but always end with [row col number].

## STRATEGIC APPROACH
- Naked Singles: If a cell has only one possible candidate, fill it immediately.
- Hidden Singles: If a number can only go in one cell within a row/column/box, place it there.
- Scanning: Look at each row, column, and box to find where numbers can go.

## COMMON PITFALLS
- Don't guess randomly - Sudoku is pure logic
- Don't overwrite pre-filled cells
- Don't repeat a move that was already made
- Coordinates are 1-indexed (1-9)

## BOARD READING
- Rows labeled R1-R9 (top to bottom)
- Columns labeled C1-C9 (left to right)
- Empty cells shown as '.'"""


def _is_valid_board_state(board_str: str) -> bool:
    return 'R1' in board_str and 'R9' in board_str and '|' in board_str


def _parse_board(board_str: str) -> list:
    grid = [[0] * 9 for _ in range(9)]
    if not _is_valid_board_state(board_str):
        return grid
    for line in board_str.split('\n'):
        line_stripped = line.strip()
        if line_stripped and line_stripped[0] == 'R' and len(line_stripped) > 1 and line_stripped[1].isdigit():
            row = int(line_stripped[1]) - 1
            col = 0
            for char in line_stripped[2:]:
                if col >= 9:
                    break
                if char == '.':
                    grid[row][col] = 0
                    col += 1
                elif char.isdigit():
                    grid[row][col] = int(char)
                    col += 1
    return grid


def _count_filled_cells(board_str: str) -> int:
    grid = _parse_board(board_str)
    return sum(1 for row in grid for cell in row if cell != 0)


def _get_valid_numbers(grid: list, row: int, col: int) -> set:
    if grid[row][col] != 0:
        return set()
    used = set()
    for c in range(9):
        if grid[row][c] != 0:
            used.add(grid[row][c])
    for r in range(9):
        if grid[r][col] != 0:
            used.add(grid[r][col])
    box_row, box_col = 3 * (row // 3), 3 * (col // 3)
    for r in range(box_row, box_row + 3):
        for c in range(box_col, box_col + 3):
            if grid[r][c] != 0:
                used.add(grid[r][c])
    return set(range(1, 10)) - used


def _extract_empty_cells_with_candidates(board_str: str, sort_by_difficulty: bool = True):
    grid = _parse_board(board_str)
    cells = []
    for row in range(9):
        for col in range(9):
            if grid[row][col] == 0:
                candidates = _get_valid_numbers(grid, row, col)
                cells.append((row + 1, col + 1, candidates))
    if sort_by_difficulty:
        cells.sort(key=lambda x: len(x[2]))
    return cells


def _extract_empty_cells(board_str: str) -> list:
    """Return list of (row, col) tuples for empty cells, 0-indexed."""
    grid = _parse_board(board_str)
    return [(r, c) for r in range(9) for c in range(9) if grid[r][c] == 0]


def _extract_board_only(text: str) -> str:
    if not text:
        return ''
    lines = text.split('\n')
    board_lines = []
    in_board = False
    for line in lines:
        stripped = line.strip()
        if stripped.startswith('C1') or (stripped and stripped[0] == 'R' and len(stripped) > 1
                                         and stripped[1].isdigit()):
            in_board = True
        if in_board and (stripped.startswith('-') or stripped.startswith('R') or stripped.startswith('C1')):
            board_lines.append(line)
        elif (in_board and stripped and not stripped.startswith('-')
              and not (stripped[0] == 'R' and len(stripped) > 1 and stripped[1].isdigit())):
            break
    return '\n'.join(board_lines) if board_lines else ''


def _make_hints(board_str: str, successful_moves: list, failed_moves: list, difficulty: str = 'easy') -> str:
    parts = []
    all_tried = successful_moves + failed_moves
    if all_tried:
        parts.append(f"\nMOVES ALREADY TRIED (do not repeat): {', '.join(all_tried[:10])}")
    if not board_str or not _is_valid_board_state(board_str):
        return '\n'.join(parts)

    cells = _extract_empty_cells_with_candidates(board_str, sort_by_difficulty=True)
    if cells:
        guaranteed = []
        other = []
        for r, c, candidates in cells[:10]:
            if len(candidates) == 1:
                guaranteed.append(f'[{r} {c} {list(candidates)[0]}]')
            elif len(candidates) <= 3:
                nums = ','.join(str(n) for n in sorted(candidates))
                other.append(f'({r},{c})->{nums}')
        if guaranteed:
            parts.append(f"\nGUARANTEED MOVES (only one option): {', '.join(guaranteed[:5])}")
        if other:
            parts.append(f"Other options: {' | '.join(other[:5])}")

    return '\n'.join(parts)


class SudokuScheduler(OpenEnvScheduler):
    """Sudoku scheduler with multi-reward and hints system.

    Tracks 5 reward components per trajectory:
    - empty_cell_reward: Did the model target empty cells? (+1/-1)
    - valid_move_reward: Were moves accepted by env? (1.0/-0.5/0.0)
    - repetition_reward: Penalty for repeating moves (exponential)
    - progress_reward: How much of the puzzle was filled (0-1)
    - correct_reward: Environment's reward (0 or 1)

    Combined reward = sum of all components.
    """

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self._last_content_len: Dict[str, int] = {}
        # Per-uuid state tracking
        self._board_states: Dict[str, str] = {}
        self._move_counts: Dict[str, defaultdict] = {}
        self._successful_moves: Dict[str, list] = {}
        self._failed_moves: Dict[str, list] = {}
        self._valid_move_scores: Dict[str, list] = {}
        self._empty_cell_scores: Dict[str, list] = {}
        self._correct_scores: Dict[str, list] = {}
        self._repetition_scores: Dict[str, list] = {}
        self._initial_filled: Dict[str, int] = {}
        self._max_filled: Dict[str, int] = {}

    async def on_trajectory_start(self, requests):
        """Initialize env, parse board, compute hints."""
        semaphore = asyncio.Semaphore(getattr(self, 'max_concurrent_envs', 4))

        async def _init_single(req):
            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 = await asyncio.to_thread(wrapper.reset)
                system_message = env_config.get('system_message', SUDOKU_SYSTEM_PROMPT)

                content = self._extract_content(obs)
                self._last_content_len[uuid] = len(content)

                # Parse initial board state
                board = _extract_board_only(content) if _is_valid_board_state(content) else content
                self._board_states[uuid] = content if _is_valid_board_state(content) else ''
                initial_filled = _count_filled_cells(self._board_states[uuid]) if self._board_states[uuid] else 0

                # Initialize tracking state
                self._move_counts[uuid] = defaultdict(int)
                self._successful_moves[uuid] = []
                self._failed_moves[uuid] = []
                self._valid_move_scores[uuid] = []
                self._empty_cell_scores[uuid] = []
                self._correct_scores[uuid] = []
                self._repetition_scores[uuid] = []
                self._initial_filled[uuid] = initial_filled
                self._max_filled[uuid] = initial_filled

                # Build initial message with board + hints
                hints = _make_hints(self._board_states[uuid], [], [])
                user_content = f'{board}{hints}' if board else content

                from swift.rollout.multi_turn import Messages
                messages = []
                if system_message:
                    messages.append({'role': 'system', 'content': system_message})
                messages.append({'role': 'user', 'content': user_content})
                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 _close_and_remove(self, uuid):
        """Override to clean up all tracking state."""
        await super()._close_and_remove(uuid)
        self._last_content_len.pop(uuid, None)
        self._board_states.pop(uuid, None)
        self._move_counts.pop(uuid, None)
        self._successful_moves.pop(uuid, None)
        self._failed_moves.pop(uuid, None)
        self._valid_move_scores.pop(uuid, None)
        self._empty_cell_scores.pop(uuid, None)
        self._correct_scores.pop(uuid, None)
        self._repetition_scores.pop(uuid, None)
        self._initial_filled.pop(uuid, None)
        self._max_filled.pop(uuid, None)

    def _extract_content(self, observation: Any) -> str:
        if isinstance(observation, dict):
            messages = observation.get('messages', [])
            if messages:
                return messages[0].get('content', '')
            prompt = observation.get('prompt', '')
            if prompt:
                return prompt
        return str(observation)

    async def on_turn_end(self, infer_request, response_choice, current_turn):
        """Parse move, step env, compute multi-reward, generate hints."""
        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)
        if action_dict is None:
            # Parse failed: end trajectory with penalty instead of polluting env
            self._total_rewards[uuid] = self._total_rewards.get(uuid, 0.0) - 1.0
            self._step_rewards.setdefault(uuid, []).append(-1.0)
            await self._close_and_remove(uuid)
            return {
                'done': True,
                'rollout_infos': {
                    'total_reward': self._total_rewards[uuid],
                    'step_rewards': list(self._step_rewards.get(uuid, [])),
                    'gym_done': True,
                }
            }
        move = action_dict.get('message', '')

        # Step environment
        obs, env_reward, done, metadata = await asyncio.to_thread(wrapper.step, action_dict)
        correct_score = float(env_reward or 0.0)

        # Extract new content (diff from last seen)
        full_content = self._extract_content(obs)
        last_len = self._last_content_len.get(uuid, 0)
        new_content = full_content[last_len:] if len(full_content) > last_len else full_content
        self._last_content_len[uuid] = len(full_content)

        # Check if env says invalid
        new_content_lower = new_content.lower()
        env_says_invalid = any(kw in new_content_lower
                               for kw in ['invalid', 'error', 'cannot', 'already', 'violation', 'lost'])

        # Check if move targets an empty cell
        if self._board_states.get(uuid):
            empty_cells = _extract_empty_cells(self._board_states[uuid])
            # Convert move coords (1-indexed from model) to 0-indexed for comparison
            move_nums = re.findall(r'\d+', move)
            targets_empty = tuple(int(x) - 1 for x in move_nums[:2]) in empty_cells if len(move_nums) >= 3 else True
        else:
            targets_empty = True

        # Empty cell reward: +1 if targeted empty, -1 if tried to overwrite
        empty_cell_score = 1.0 if targets_empty else -1.0

        # Repetition tracking
        is_new_move = self._move_counts[uuid][move] == 0
        repetition_count = self._move_counts[uuid][move]
        self._move_counts[uuid][move] += 1
        repetition_score = -min(2**repetition_count, 10.0) if repetition_count > 0 else 0.0

        # Valid move score
        is_valid = not env_says_invalid and targets_empty
        if is_valid and is_new_move:
            valid_move_score = 1.0
            self._successful_moves[uuid].append(move)
        elif 'please resubmit' in new_content_lower or 'avoid penalties' in new_content_lower:
            valid_move_score = -0.5
            self._failed_moves[uuid].append(move)
        else:
            valid_move_score = 0.0
            if not is_valid:
                self._failed_moves[uuid].append(move)

        # Update board state if valid and new content has board
        if is_valid and _is_valid_board_state(new_content):
            self._board_states[uuid] = new_content
            current_filled = _count_filled_cells(new_content)
            if current_filled > self._max_filled[uuid]:
                self._max_filled[uuid] = current_filled

        # Progress reward
        remaining = 81 - self._initial_filled[uuid]
        if remaining > 0:
            progress_score = (self._max_filled[uuid] - self._initial_filled[uuid]) / remaining
        else:
            progress_score = 1.0

        # Track all scores
        self._valid_move_scores[uuid].append(valid_move_score)
        self._empty_cell_scores[uuid].append(empty_cell_score)
        self._correct_scores[uuid].append(correct_score)
        self._repetition_scores[uuid].append(repetition_score)

        combined_reward = (
            sum(self._empty_cell_scores[uuid]) / max(len(self._empty_cell_scores[uuid]), 1)
            + sum(self._valid_move_scores[uuid]) / max(len(self._valid_move_scores[uuid]), 1)
            + sum(self._repetition_scores[uuid]) / max(len(self._repetition_scores[uuid]), 1) + progress_score
            + correct_score)

        self._total_rewards[uuid] = combined_reward
        self._step_rewards.setdefault(uuid, []).append(combined_reward)

        # Build next observation with board + hints
        if not done:
            board_str = self._board_states.get(uuid, '')
            board = _extract_board_only(board_str) if board_str else ''
            hints = _make_hints(
                board_str,
                self._successful_moves[uuid],
                self._failed_moves[uuid],
            )
            step_num = len(self._successful_moves[uuid])
            next_obs = f'Step {step_num}. Progress: {step_num} cells filled.\n\nBoard:\n{board}{hints}'
        else:
            next_obs = None

        self._pending_obs[uuid] = next_obs

        rollout_infos = {
            'total_reward': self._total_rewards[uuid],
            'step_rewards': list(self._step_rewards.get(uuid, [])),
            'gym_done': done,
            'empty_cell_reward': sum(self._empty_cell_scores[uuid]) / max(len(self._empty_cell_scores[uuid]), 1),
            'valid_move_reward': sum(self._valid_move_scores[uuid]) / max(len(self._valid_move_scores[uuid]), 1),
            'repetition_reward': sum(self._repetition_scores[uuid]) / max(len(self._repetition_scores[uuid]), 1),
            'progress_reward': progress_score,
            'correct_reward': correct_score,
        }
        if done:
            await self._close_and_remove(uuid)

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

    def parse_action(self, text: str) -> Optional[Dict[str, Any]]:
        """Extract [row col number] from model output. Returns None if parse fails."""
        match = re.search(r'\[\s*(\d+)\s+(\d+)\s+(\d+)\s*\]', text)
        if match:
            row, col, num = match.groups()
            return {'message': f'[{row} {col} {num}]'}

        numbers = re.findall(r'\d+', text)
        if len(numbers) >= 3:
            return {'message': f'[{numbers[0]} {numbers[1]} {numbers[2]}]'}

        return None

    def format_observation(self, observation: Any) -> Union[str, List[Dict]]:
        return self._extract_content(observation)


# Register scheduler so --external_plugins can load it

multi_turns['sudoku_scheduler'] = SudokuScheduler
