#
# A trivial user interface for a Z-Machine that uses (mostly) stdio for
# everything and supports little to no optional features.
#
# For the license of this file, please consult the LICENSE file in the
# root directory of this distribution.
#

# TODO: There are a few edge-cases in this UI implementation in
# regards to word-wrapping.  For example, if keyboard input doesn't
# terminate in a newline, then word-wrapping can be temporarily thrown
# off; the text I/O performed by the audio and filesystem doesn't
# really communicate with the screen object, which means that
# operations performed by them can temporarily throw off word-wrapping
# as well.

import sys
from functools import reduce

from . import zaudio, zfilesystem, zscreen, zstream, zui
from .zlogging import log


class TrivialAudio(zaudio.ZAudio):
    def __init__(self):
        zaudio.ZAudio.__init__(self)
        self.features = {
            "has_more_than_a_bleep": False,
        }

    def play_bleep(self, bleep_type):
        if bleep_type == zaudio.BLEEP_HIGH:
            sys.stdout.write("AUDIO: high-pitched bleep\n")
        elif bleep_type == zaudio.BLEEP_LOW:
            sys.stdout.write("AUDIO: low-pitched bleep\n")
        else:
            raise AssertionError(f"Invalid bleep_type: {str(bleep_type)}")


class TrivialScreen(zscreen.ZScreen):
    def __init__(self):
        zscreen.ZScreen.__init__(self)
        self.__styleIsAllUppercase = False

        # Current column of text being printed.
        self.__curr_column = 0

        # Number of rows displayed since we last took input; needed to
        # keep track of when we need to display the [MORE] prompt.
        self.__rows_since_last_input = 0

    def split_window(self, height):
        log(f"TODO: split window here to height {height}")

    def select_window(self, window):
        log(f"TODO: select window {window} here")

    def set_cursor_position(self, x, y):
        log(f"TODO: set cursor position to ({x},{y}) here")

    def erase_window(self, window=zscreen.WINDOW_LOWER, color=zscreen.COLOR_CURRENT):
        for _row in range(self._rows):
            sys.stdout.write("\n")
        self.__curr_column = 0
        self.__rows_since_last_input = 0

    def set_font(self, font_number):
        if font_number == zscreen.FONT_NORMAL:
            return font_number
        else:
            # We aren't going to support anything but the normal font.
            return None

    def set_text_style(self, style):
        # We're pretty much limited to stdio here; even if we might be
        # able to use terminal hackery under Unix, supporting styled text
        # in a Windows console is problematic [1].  The closest thing we
        # can do is have our "bold" style be all-caps, so we'll do that.
        #
        # [1] http://mail.python.org/pipermail/tutor/2004-February/028474.html

        if style == zscreen.STYLE_BOLD:
            self.__styleIsAllUppercase = True
        else:
            self.__styleIsAllUppercase = False

    def __show_more_prompt(self):
        """Display a [MORE] prompt, wait for the user to press a key, and
        then erase the [MORE] prompt, leaving the cursor at the same
        position that it was at before the call was made."""

        assert self.__curr_column == 0, "Precondition: current column must be zero."

        MORE_STRING = "[MORE]"
        sys.stdout.write(MORE_STRING)
        _read_char()
        # Erase the [MORE] prompt and reset the cursor position.
        sys.stdout.write(f"\r{' ' * len(MORE_STRING)}\r")
        self.__rows_since_last_input = 0

    def on_input_occurred(self, newline_occurred=False):
        """Callback function that should be called whenever keyboard input
        has occurred; this is so we can keep track of when we need to
        display a [MORE] prompt."""

        self.__rows_since_last_input = 0
        if newline_occurred:
            self.__curr_column = 0

    def __unbuffered_write(self, string):
        """Write the given string, inserting newlines at the end of
        columns as appropriate, and displaying [MORE] prompts when
        appropriate.  This function does not perform word-wrapping."""

        for char in string:
            newline_printed = False
            sys.stdout.write(char)
            sys.stdout.flush()

            if char == "\n":
                newline_printed = True
            else:
                self.__curr_column += 1

            if self.__curr_column == self._columns:
                sys.stdout.write("\n")
                newline_printed = True

            if newline_printed:
                self.__rows_since_last_input += 1
                self.__curr_column = 0
                if (
                    self.__rows_since_last_input == self._rows
                    and self._rows != zscreen.INFINITE_ROWS
                ):
                    self.__show_more_prompt()

    def write(self, string):
        if self.__styleIsAllUppercase:
            # Apply our fake "bold" transformation.
            string = string.upper()

        if self.buffer_mode:
            # This is a hack to get words to wrap properly, based on our
            # current cursor position.

            # First, add whitespace padding up to the column of text that
            # we're at.
            string = (" " * self.__curr_column) + string

            # Next, word wrap our current string.
            string = _word_wrap(string, self._columns - 1)

            # Now remove the whitespace padding.
            string = string[self.__curr_column :]

        self.__unbuffered_write(string)


class TrivialKeyboardInputStream(zstream.ZInputStream):
    def __init__(self, screen):
        zstream.ZInputStream.__init__(self)
        self.__screen = screen
        self.features = {
            "has_timed_input": False,
        }

    def read_line(
        self,
        original_text=None,
        max_length=0,
        terminating_characters=None,
        timed_input_routine=None,
        timed_input_interval=0,
    ):
        result = _read_line(original_text, terminating_characters)
        if max_length > 0:
            result = result[:max_length]

        # TODO: The value of 'newline_occurred' here is not accurate,
        # because terminating_characters may include characters other than
        # carriage return.
        self.__screen.on_input_occurred(newline_occurred=True)

        return str(result)

    def read_char(self, timed_input_routine=None, timed_input_interval=0):
        result = _read_char()
        self.__screen.on_input_occurred()
        return ord(result)


class TrivialFilesystem(zfilesystem.ZFilesystem):
    def __report_io_error(self, exception):
        sys.stdout.write(f"FILESYSTEM: An error occurred: {exception}\n")

    def save_game(self, data, suggested_filename=None):
        success = False

        sys.stdout.write("Enter a name for the saved game (hit enter to cancel): ")
        filename = _read_line(suggested_filename)
        if filename:
            try:
                with open(filename, "wb") as file_obj:
                    file_obj.write(data)
                success = True
            except OSError as e:
                self.__report_io_error(e)

        return success

    def restore_game(self):
        data = None

        sys.stdout.write(
            "Enter the name of the saved game to restore (hit enter to cancel): "
        )
        filename = _read_line()
        if filename:
            try:
                with open(filename, "rb") as file_obj:
                    data = file_obj.read()
            except OSError as e:
                self.__report_io_error(e)

        return data

    def open_transcript_file_for_writing(self):
        file_obj = None

        sys.stdout.write("Enter a name for the transcript file (hit enter to cancel): ")
        filename = _read_line()
        if filename:
            try:
                file_obj = open(filename, "w")  # noqa: SIM115
            except OSError as e:
                self.__report_io_error(e)

        return file_obj

    def open_transcript_file_for_reading(self):
        file_obj = None

        sys.stdout.write(
            "Enter the name of the transcript file to read (hit enter to cancel): "
        )
        filename = _read_line()
        if filename:
            try:
                file_obj = open(filename)  # noqa: SIM115
            except OSError as e:
                self.__report_io_error(e)

        return file_obj


def create_zui():
    """Creates and returns a ZUI instance representing a trivial user
    interface."""

    audio = TrivialAudio()
    screen = TrivialScreen()
    keyboard_input = TrivialKeyboardInputStream(screen)
    filesystem = TrivialFilesystem()

    return zui.ZUI(audio, screen, keyboard_input, filesystem)


# Keyboard input functions

_INTERRUPT_CHAR = chr(3)
_BACKSPACE_CHAR = chr(8)
_DELETE_CHAR = chr(127)


def _win32_read_char():
    """Win32-specific function that reads a character of input from the
    keyboard and returns it without printing it to the screen."""

    import msvcrt

    return str(msvcrt.getch())  # type: ignore[possibly-missing-attribute]


def _unix_read_char():
    """Unix-specific function that reads a character of input from the
    keyboard and returns it without printing it to the screen."""

    # This code was excised from:
    # http://aspn.activestate.com/ASPN/Cookbook/Python/Recipe/134892

    import termios
    import tty

    fd = sys.stdin.fileno()

    # Check if stdin is a TTY - if not, use simple read
    if not sys.stdin.isatty():
        return sys.stdin.read(1)

    old_settings = termios.tcgetattr(fd)
    try:
        tty.setraw(sys.stdin.fileno())
        ch = sys.stdin.read(1)
    finally:
        termios.tcsetattr(fd, termios.TCSADRAIN, old_settings)
    return str(ch)


def _read_char():
    """Reads a character of input from the keyboard and returns it
    without printing it to the screen."""

    if sys.platform == "win32":
        _platform_read_char = _win32_read_char
    else:
        # We're not running on Windows, so assume we're running on Unix.
        _platform_read_char = _unix_read_char

    char = _platform_read_char()
    if char == _INTERRUPT_CHAR:
        raise KeyboardInterrupt()
    else:
        return char


def _read_line(original_text=None, terminating_characters=None):
    """Reads a line of input with the given unicode string of original
    text, which is editable, and the given unicode string of terminating
    characters (used to terminate text input).  By default,
    terminating_characters is a string containing the carriage return
    character ('\r')."""

    if original_text is None:
        original_text = ""
    if not terminating_characters:
        terminating_characters = "\r"

    assert isinstance(original_text, str)
    assert isinstance(terminating_characters, str)

    # If stdin is not a TTY, use simple line reading
    if not sys.stdin.isatty():
        line = sys.stdin.readline()
        if not line:  # EOF
            raise EOFError("End of input")
        # Strip newline but keep the content
        return line.rstrip("\n\r")

    chars_entered = len(original_text)
    sys.stdout.write(original_text)
    string = original_text
    finished = False
    while not finished:
        char = _read_char()

        if char in (_BACKSPACE_CHAR, _DELETE_CHAR):
            if chars_entered > 0:
                chars_entered -= 1
                string = string[:-1]
            else:
                continue
        elif char in terminating_characters:
            finished = True
        else:
            string += char
            chars_entered += 1

        if char == "\r":
            char_to_print = "\n"
        elif char in (_BACKSPACE_CHAR, _DELETE_CHAR):
            char_to_print = f"{_BACKSPACE_CHAR} {_BACKSPACE_CHAR}"
        else:
            char_to_print = char

        sys.stdout.write(char_to_print)
        sys.stdout.flush()
    return string


# Word wrapping helper function


def _word_wrap(text, width):
    """
    A word-wrap function that preserves existing line breaks
    and most spaces in the text. Expects that existing line
    breaks are posix newlines (\n).
    """

    # This code was taken from:
    # http://aspn.activestate.com/ASPN/Cookbook/Python/Recipe/148061

    return reduce(
        lambda line, word, width=width: "{}{}{}".format(
            line,
            " \n"[
                (
                    len(line) - line.rfind("\n") - 1 + len(word.split("\n", 1)[0])
                    >= width
                )
            ],
            word,
        ),
        text.split(" "),
    )
