# Copyright 2022 The Pigweed Authors
#
# Licensed under the Apache License, Version 2.0 (the "License"); you may not
# use this file except in compliance with the License. You may obtain a copy of
# the License at
#
#     https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations under
# the License.
"""Example Plugin that displays some dynamic content: a game of 2048."""

from random import choice
from typing import Iterable, List, Tuple, TYPE_CHECKING
import time

from prompt_toolkit.filters import has_focus
from prompt_toolkit.formatted_text import StyleAndTextTuples
from prompt_toolkit.key_binding import KeyBindings, KeyPressEvent
from prompt_toolkit.layout import (
    AnyContainer,
    Dimension,
    FormattedTextControl,
    HSplit,
    Window,
    WindowAlign,
    VSplit,
)
from prompt_toolkit.mouse_events import MouseEvent, MouseEventType
from prompt_toolkit.widgets import MenuItem

from pw_console.widgets import (
    create_border,
    FloatingWindowPane,
    ToolbarButton,
    WindowPaneToolbar,
)
from pw_console.plugin_mixin import PluginMixin
from pw_console.get_pw_console_app import get_pw_console_app

if TYPE_CHECKING:
    from pw_console.console_app import ConsoleApp

Twenty48Cell = Tuple[int, int, int]


class Twenty48Game:
    """2048 Game."""

    def __init__(self) -> None:
        self.colors = {
            2: 'bg:#dd6',
            4: 'bg:#da6',
            8: 'bg:#d86',
            16: 'bg:#d66',
            32: 'bg:#d6a',
            64: 'bg:#a6d',
            128: 'bg:#66d',
            256: 'bg:#68a',
            512: 'bg:#6a8',
            1024: 'bg:#6d6',
            2048: 'bg:#0f8',
            4096: 'bg:#0ff',
        }
        self.board: List[List[int]]
        self.last_board: List[Twenty48Cell]
        self.move_count: int
        self.width: int = 4
        self.height: int = 4
        self.max_value: int = 0
        self.start_time: float
        self.reset_game()

    def reset_game(self) -> None:
        self.start_time = time.time()
        self.max_value = 2
        self.move_count = 0
        self.board = []
        for _i in range(self.height):
            self.board.append([0] * self.width)
        self.last_board = list(self.all_cells())
        self.add_random_tiles(2)

    def stats(self) -> StyleAndTextTuples:
        """Returns stats on the game in progress."""
        elapsed_time = int(time.time() - self.start_time)
        minutes = int(elapsed_time / 60.0)
        seconds = elapsed_time % 60
        fragments: StyleAndTextTuples = []
        fragments.append(('', '\n'))
        fragments.append(('', f'Moves: {self.move_count}'))
        fragments.append(('', '\n'))
        fragments.append(('', 'Time:  {:0>2}:{:0>2}'.format(minutes, seconds)))
        fragments.append(('', '\n'))
        fragments.append(('', f'Max: {self.max_value}'))
        fragments.append(('', '\n\n'))
        fragments.append(('', 'Press R to restart\n'))
        fragments.append(('', '\n'))
        fragments.append(('', 'Arrow keys to move'))
        return fragments

    def __pt_formatted_text__(self) -> StyleAndTextTuples:
        """Returns the game board formatted in a grid with colors."""
        fragments: StyleAndTextTuples = []

        def print_row(row: List[int], include_number: bool = False) -> None:
            fragments.append(('', '  '))
            for col in row:
                style = 'class:theme-fg-default '
                if col > 0:
                    style = '#000 '
                style += self.colors.get(col, '')
                text = ' ' * 6
                if include_number:
                    text = '{:^6}'.format(col)
                fragments.append((style, text))
            fragments.append(('', '\n'))

        fragments.append(('', '\n'))
        for row in self.board:
            print_row(row)
            print_row(row, include_number=True)
            print_row(row)

        return fragments

    def __repr__(self) -> str:
        board = ''
        for row_cells in self.board:
            for column in row_cells:
                board += '{:^6}'.format(column)
            board += '\n'
        return board

    def all_cells(self) -> Iterable[Twenty48Cell]:
        for row, row_cells in enumerate(self.board):
            for col, cell_value in enumerate(row_cells):
                yield (row, col, cell_value)

    def update_max_value(self) -> None:
        for _row, _col, value in self.all_cells():
            if value > self.max_value:
                self.max_value = value

    def empty_cells(self) -> Iterable[Twenty48Cell]:
        for row, row_cells in enumerate(self.board):
            for col, cell_value in enumerate(row_cells):
                if cell_value != 0:
                    continue
                yield (row, col, cell_value)

    def _board_changed(self) -> bool:
        return self.last_board != list(self.all_cells())

    def complete_move(self) -> None:
        if not self._board_changed():
            # Move did nothing, ignore.
            return

        self.update_max_value()
        self.move_count += 1
        self.add_random_tiles()
        self.last_board = list(self.all_cells())

    def add_random_tiles(self, count: int = 1) -> None:
        for _i in range(count):
            empty_cells = list(self.empty_cells())
            if not empty_cells:
                return
            row, col, _value = choice(empty_cells)
            self.board[row][col] = 2

    def row(self, row_index: int) -> Iterable[Twenty48Cell]:
        for col, cell_value in enumerate(self.board[row_index]):
            yield (row_index, col, cell_value)

    def col(self, col_index: int) -> Iterable[Twenty48Cell]:
        for row, row_cells in enumerate(self.board):
            for col, cell_value in enumerate(row_cells):
                if col == col_index:
                    yield (row, col, cell_value)

    def non_zero_row_values(self, index: int) -> Tuple[List, List]:
        non_zero_values = [
            value for row, col, value in self.row(index) if value != 0
        ]
        padding = [0] * (self.width - len(non_zero_values))
        return (non_zero_values, padding)

    def move_right(self) -> None:
        for i in range(self.height):
            non_zero_values, padding = self.non_zero_row_values(i)
            self.board[i] = padding + non_zero_values

    def move_left(self) -> None:
        for i in range(self.height):
            non_zero_values, padding = self.non_zero_row_values(i)
            self.board[i] = non_zero_values + padding

    def add_horizontal(self, reverse=False) -> None:
        for i in range(self.width):
            this_row = list(self.row(i))
            if reverse:
                this_row = list(reversed(this_row))
            for row, col, this_cell in this_row:
                if this_cell == 0 or col >= self.width - 1:
                    continue
                next_cell = self.board[row][col + 1]
                if this_cell == next_cell:
                    self.board[row][col] = 0
                    self.board[row][col + 1] = this_cell * 2
                    break

    def non_zero_col_values(self, index: int) -> Tuple[List, List]:
        non_zero_values = [
            value for row, col, value in self.col(index) if value != 0
        ]
        padding = [0] * (self.height - len(non_zero_values))
        return (non_zero_values, padding)

    def _set_column(self, col_index: int, values: List[int]) -> None:
        for row, value in enumerate(values):
            self.board[row][col_index] = value

    def add_vertical(self, reverse=False) -> None:
        for i in range(self.height):
            this_column = list(self.col(i))
            if reverse:
                this_column = list(reversed(this_column))
            for row, col, this_cell in this_column:
                if this_cell == 0 or row >= self.height - 1:
                    continue
                next_cell = self.board[row + 1][col]
                if this_cell == next_cell:
                    self.board[row][col] = 0
                    self.board[row + 1][col] = this_cell * 2
                    break

    def move_down(self) -> None:
        for col_index in range(self.width):
            non_zero_values, padding = self.non_zero_col_values(col_index)
            self._set_column(col_index, padding + non_zero_values)

    def move_up(self) -> None:
        for col_index in range(self.width):
            non_zero_values, padding = self.non_zero_col_values(col_index)
            self._set_column(col_index, non_zero_values + padding)

    def press_down(self) -> None:
        self.move_down()
        self.add_vertical(reverse=True)
        self.move_down()
        self.complete_move()

    def press_up(self) -> None:
        self.move_up()
        self.add_vertical()
        self.move_up()
        self.complete_move()

    def press_right(self) -> None:
        self.move_right()
        self.add_horizontal(reverse=True)
        self.move_right()
        self.complete_move()

    def press_left(self) -> None:
        self.move_left()
        self.add_horizontal()
        self.move_left()
        self.complete_move()


class Twenty48Control(FormattedTextControl):
    """Example prompt_toolkit UIControl for displaying formatted text.

    This is the prompt_toolkit class that is responsible for drawing the 2048,
    handling keybindings if in focus, and mouse input.
    """

    def __init__(self, twenty48_pane: 'Twenty48Pane', *args, **kwargs) -> None:
        self.twenty48_pane = twenty48_pane
        self.game = self.twenty48_pane.game

        # Set some custom key bindings to toggle the view mode and wrap lines.
        key_bindings = KeyBindings()

        @key_bindings.add('R')
        def _restart(_event: KeyPressEvent) -> None:
            """Restart the game."""
            self.game.reset_game()

        @key_bindings.add('q')
        def _quit(_event: KeyPressEvent) -> None:
            """Quit the game."""
            self.twenty48_pane.close_dialog()

        @key_bindings.add('j')
        @key_bindings.add('down')
        def _move_down(_event: KeyPressEvent) -> None:
            """Move down"""
            self.game.press_down()

        @key_bindings.add('k')
        @key_bindings.add('up')
        def _move_up(_event: KeyPressEvent) -> None:
            """Move up."""
            self.game.press_up()

        @key_bindings.add('h')
        @key_bindings.add('left')
        def _move_left(_event: KeyPressEvent) -> None:
            """Move left."""
            self.game.press_left()

        @key_bindings.add('l')
        @key_bindings.add('right')
        def _move_right(_event: KeyPressEvent) -> None:
            """Move right."""
            self.game.press_right()

        # Include the key_bindings keyword arg when passing to the parent class
        # __init__ function.
        kwargs['key_bindings'] = key_bindings
        # Call the parent FormattedTextControl.__init__
        super().__init__(*args, **kwargs)

    def mouse_handler(self, mouse_event: MouseEvent):
        """Mouse handler for this control."""
        # If the user clicks anywhere this function is run.

        # Mouse positions relative to this control. x is the column starting
        # from the left size as zero. y is the row starting with the top as
        # zero.
        _click_x = mouse_event.position.x
        _click_y = mouse_event.position.y

        # Mouse click behavior usually depends on if this window pane is in
        # focus. If not in focus, then focus on it when left clicking. If
        # already in focus then perform the action specific to this window.

        # If not in focus, change focus to this 2048 pane and do nothing else.
        if not has_focus(self.twenty48_pane)():
            if mouse_event.event_type == MouseEventType.MOUSE_UP:
                get_pw_console_app().focus_on_container(self.twenty48_pane)
                # Mouse event handled, return None.
                return None

        # If code reaches this point, this window is already in focus.
        # if mouse_event.event_type == MouseEventType.MOUSE_UP:
        #     # Toggle the view mode.
        #     self.twenty48_pane.toggle_view_mode()
        #     # Mouse event handled, return None.
        #     return None

        # Mouse event not handled, return NotImplemented.
        return NotImplemented


class Twenty48Pane(FloatingWindowPane, PluginMixin):
    """Example Pigweed Console plugin to play 2048.

    The Twenty48Pane is a WindowPane based plugin that displays an interactive
    game of 2048. It inherits from both WindowPane and PluginMixin. It can be
    added on console startup by calling: ::

        my_console.add_window_plugin(Twenty48Pane())

    For an example see:
    https://pigweed.dev/pw_console/embedding.html#adding-plugins
    """

    def __init__(self, include_resize_handle: bool = True, **kwargs):
        super().__init__(
            pane_title='2048',
            height=Dimension(preferred=17),
            width=Dimension(preferred=50),
            **kwargs,
        )
        self.game = Twenty48Game()

        # Hide by default.
        self.show_pane = False

        # Create a toolbar for display at the bottom of the 2048 window. It
        # will show the window title and buttons.
        self.bottom_toolbar = WindowPaneToolbar(
            self, include_resize_handle=include_resize_handle
        )

        # Add a button to restart the game.
        self.bottom_toolbar.add_button(
            ToolbarButton(
                key='R',  # Key binding help text for this function
                description='Restart',  # Button name
                # Function to run when clicked.
                mouse_handler=self.game.reset_game,
            )
        )
        # Add a button to restart the game.
        self.bottom_toolbar.add_button(
            ToolbarButton(
                key='q',  # Key binding help text for this function
                description='Quit',  # Button name
                # Function to run when clicked.
                mouse_handler=self.close_dialog,
            )
        )

        # Every FormattedTextControl object (Twenty48Control) needs to live
        # inside a prompt_toolkit Window() instance. Here is where you specify
        # alignment, style, and dimensions. See the prompt_toolkit docs for all
        # opitons:
        # https://python-prompt-toolkit.readthedocs.io/en/latest/pages/reference.html#prompt_toolkit.layout.Window
        self.twenty48_game_window = Window(
            # Set the content to a Twenty48Control instance.
            content=Twenty48Control(
                self,  # This Twenty48Pane class
                self.game,  # Content from Twenty48Game.__pt_formatted_text__()
                show_cursor=False,
                focusable=True,
            ),
            # Make content left aligned
            align=WindowAlign.LEFT,
            # These two set to false make this window fill all available space.
            dont_extend_width=True,
            dont_extend_height=False,
            wrap_lines=False,
            width=Dimension(preferred=28),
            height=Dimension(preferred=15),
        )

        self.twenty48_stats_window = Window(
            content=Twenty48Control(
                self,  # This Twenty48Pane class
                self.game.stats,  # Content from Twenty48Game.stats()
                show_cursor=False,
                focusable=True,
            ),
            # Make content left aligned
            align=WindowAlign.LEFT,
            # These two set to false make this window fill all available space.
            width=Dimension(preferred=20),
            dont_extend_width=False,
            dont_extend_height=False,
            wrap_lines=False,
        )

        # self.container is the root container that contains objects to be
        # rendered in the UI, one on top of the other.
        self.container = self._create_pane_container(
            create_border(
                HSplit(
                    [
                        # Vertical split content
                        VSplit(
                            [
                                # Left side will show the game board.
                                self.twenty48_game_window,
                                # Stats will be shown on the right.
                                self.twenty48_stats_window,
                            ]
                        ),
                        # The bottom_toolbar is shown below the VSplit.
                        self.bottom_toolbar,
                    ]
                ),
                title='2048',
                border_style='class:command-runner-border',
                # left_margin_columns=1,
                # right_margin_columns=1,
            )
        )

        self.dialog_content: List[AnyContainer] = [
            # Vertical split content
            VSplit(
                [
                    # Left side will show the game board.
                    self.twenty48_game_window,
                    # Stats will be shown on the right.
                    self.twenty48_stats_window,
                ]
            ),
            # The bottom_toolbar is shown below the VSplit.
            self.bottom_toolbar,
        ]
        # Wrap the dialog content in a border
        self.bordered_dialog_content = create_border(
            HSplit(self.dialog_content),
            title='2048',
            border_style='class:command-runner-border',
        )
        # self.container is the root container that contains objects to be
        # rendered in the UI, one on top of the other.
        if include_resize_handle:
            self.container = self._create_pane_container(*self.dialog_content)
        else:
            self.container = self._create_pane_container(
                self.bordered_dialog_content
            )

        # This plugin needs to run a task in the background periodically and
        # uses self.plugin_init() to set which function to run, and how often.
        # This is provided by PluginMixin. See the docs for more info:
        # https://pigweed.dev/pw_console/plugins.html#background-tasks
        self.plugin_init(
            plugin_callback=self._background_task,
            # Run self._background_task once per second.
            plugin_callback_frequency=1.0,
            plugin_logger_name='pw_console_example_2048_plugin',
        )

    def get_top_level_menus(self) -> List[MenuItem]:
        def _toggle_dialog() -> None:
            self.toggle_dialog()

        return [
            MenuItem(
                '[2048]',
                children=[
                    MenuItem(
                        'Example Top Level Menu', handler=None, disabled=True
                    ),
                    # Menu separator
                    MenuItem('-', None),
                    MenuItem('Show/Hide 2048 Game', handler=_toggle_dialog),
                    MenuItem('Restart', handler=self.game.reset_game),
                ],
            ),
        ]

    def pw_console_init(self, app: 'ConsoleApp') -> None:
        """Set the Pigweed Console application instance.

        This function is called after the Pigweed Console starts up and allows
        access to the user preferences. Prefs is required for creating new
        user-remappable keybinds."""
        self.application = app

    def _background_task(self) -> bool:
        """Function run in the background for the ClockPane plugin."""
        # Optional: make a log message for debugging purposes. For more info
        # see:
        # https://pigweed.dev/pw_console/plugins.html#debugging-plugin-behavior
        # self.plugin_logger.debug('background_task_update_count: %s',
        #                          self.background_task_update_count)

        # Returning True in the background task will force the user interface to
        # re-draw.
        # Returning False means no updates required.

        if self.show_pane:
            # Return true so the game clock is updated.
            return True

        # Game window is hidden, don't redraw.
        return False
