#!/usr/bin/env python3

import sys
import os
import code
import readline
import atexit
import subprocess
from collections import Iterable
import tempfile
import traceback
import textwrap

# Add the current directory to PATH
sys.path.append(os.getcwd())

class BufferError(Exception): pass

SCREEN_WIDTH = None
def get_screen_width():
    global SCREEN_WIDTH

    if SCREEN_WIDTH is not None:
        return SCREEN_WIDTH

    if sys.version_info > (3,3):
        import shutil
        w,h = shutil.get_terminal_size()
    else:
        import fcntl, termios, struct
        h, w, hp, wp = struct.unpack('HHHH',
            fcntl.ioctl(0, termios.TIOCGWINSZ,
            struct.pack('HHHH', 0, 0, 0, 0)))

    SCREEN_WIDTH = w
    return SCREEN_WIDTH

class BufferFile(object):
    """Currently a simply proxy class to a file object. However, I expect this
    class become more complicated over time, so it makes sense to go ahead and
    create the buffer as a class now to save re-factoring work later.
    """
    def __init__(self, path=None):
        if path is not None:
            self.path = os.path.realpath(path)
            try:
                if sys.version_info < (3,0):
                    self.file = open(self.path, 'a+', 0)
                else:
                    self.file = open(self.path, 'a+')
            except (IOError, OSError) as e:
                # Catch exception, but don't treat it as fatal
                print('Failed when trying to open named buffer: ' + str(e))
                raise BufferError()
        else:
            (fd, self.path) = tempfile.mkstemp(prefix='.', suffix='.buffer', dir='.')
            if sys.version_info < (3,0):
                self.file = os.fdopen(fd, 'r+', 0)
            else:
                self.file = os.fdopen(fd, 'r+')

    def __del__(self):
        self.close()

    def close(self):
        if self.file is not None:
            self.file.close()
            self.file = None

    def destroy(self):
        self.close()
        os.unlink(self.path)

class BufferFileManager(object):
    """
    General Note on Index vs. Buffer Number:
    Vim uses 1-based indexing for buffers. Sane people use 0-based indexes for...
    everything else. As a design principle, an index should remain an index for
    everything under the hood, until direct interaction with Vim is required.
    Additionally, what's presented to the user should be as a buffer number,
    since we want the user to associate the buffer number in Shython and the buffer
    number in Vim as the same thing.
    """
    def __init__(self):
        self.buffers = list()

    def __del__(self):
        for buffer in self.buffers:
            buffer.close()

    def _is_unique_buffer(self, buffer):
        for other in self.buffers:
            if other.path == buffer.path:
                return False
        return True

    @staticmethod
    def _buffer_number_to_index(buffer_number):
        return buffer_number - 1

    @staticmethod
    def _index_to_buffer_number(index):
        return index + 1

    def _validate_index(self, index):
        if index >= len(self.buffers):
            print('Invalid buffer \'{}\''.format(self._index_to_buffer_number(index)))
            return False
        return True

    def _validate_buffer_number(self, buffer_number):
        return self._validate_index(self._buffer_number_to_index(buffer_number))

    def remove_buffer(self, buffer_number):
        index = self._buffer_number_to_index(buffer_number)
        if self._validate_index(index) is False:
            return

        self.buffers[index].close()
        self.buffers.pop(index)

    def destroy_buffer(self, buffer_number):
        index = self._buffer_number_to_index(buffer_number)
        if self._validate_index(index) is False:
            return

        self.buffers[index].destroy()
        self.buffers.pop(index)

    def add_buffer(self, path=None):
        try:
            new_buffer = BufferFile(path=path)
            if self._is_unique_buffer(new_buffer) is True:
                self.buffers.append(new_buffer)
        except BufferError:
            pass

    def edit_buffers(self, buffer_numbers=list()):
        command = ['vim', '+set backupcopy=yes']

        for buffer_number in buffer_numbers:
            index = self._buffer_number_to_index(buffer_number)

            if self._validate_index(index) is True:
                command.append(self.buffers[index].path)

        subprocess.call(command)

    def get_buffer_path(self, buffer_number):
        index = self._buffer_number_to_index(buffer_number)
        if self._validate_index(index) is False:
            return
        return self.buffers[index].path

    def list_buffers(self):
        if len(self.buffers) == 0:
            print('Buffers: None')
            return

        print('Buffers:')
        for idx, buffer in enumerate(self.buffers):
            print('{0}: {1}'.format(self._index_to_buffer_number(idx), buffer.path))

class ShythonConsole(code.InteractiveConsole):
    help_descriptions = [
        ('help',   '[COMMAND]',
                   ('Print this message and exit. If a command is provided, that command\'s '
                   'help message is printed instead.')),
        ('badd',   '[PATH ...]',
                   ('Add a buffer. If a path is provided, open a buffer backed by '
                    'that file instead of a new file.')),
        ('bdel',   '<BUFFER_NUM> [...]',
                   ('Delete a buffer as referenced by its buffer number. One or more '
                    'buffer numbers must be provided.')),
        ('bed',    '<BUFFER_NUM> [...]',
                   ('Edit a buffer as referenced by its buffer number. One or more '
                    'buffer numbers must be provided.')),
        ('binit',  '[PATH]',
                   ('Re-initialize the buffer list. If a path is provided, it is used '
                    'as the root directory instead of the current directory.')),
        ('bls',    '',
                   'List all buffers.'),
        ('brun',   '<BUFFER_NUM> [...]',
                   ('Execute a buffer as referenced by its buffer number. One or more '
                    'buffer numbers must be provided.After executing the buffer, any '
                    'classes, method, or variables created by the buffer will be '
                    'available to the interpreter. NOTE: Buffers execute in the interpreter '
                    'global scope. A reference will be clobbered if an object of the '
                    'same name is changed in the buffer.')),
    ]

    def __init__(self, locals=None):
        self.orig_raw_input = self.raw_input
        self.raw_input = self._raw_input

        self._init_manager()

        code.InteractiveConsole.__init__(self,
                                         locals=locals,
                                         filename='<console>')

        self._init_history()

    def _init_history(self):
        """Initializes history file."""
        histfile = '.shython_history'
        readline.parse_and_bind("tab: complete")
        if hasattr(readline, "read_history_file"):
            try:
                readline.read_history_file(histfile)
            except IOError:
                pass
            atexit.register(self._save_history, histfile)

    def _save_history(self, histfile):
        """Saves history to history file."""
        readline.set_history_length(1000)
        readline.write_history_file(histfile)

    def _raw_input(self, prompt):
        """Overrides default raw_input method to inject Shython functions."""
        input_received = self.orig_raw_input(prompt=prompt)

        if input_received.startswith(':'):
            input_received = input_received[1:]

            input_delimited = input_received.split()

            command = input_delimited.pop(0)
            args = input_delimited

            if command == 'help':
                return self.do_help(args)
            elif command == 'badd':
                return self.do_badd(args)
            elif command == 'bdel':
                return self.do_bdel(args)
            elif command == 'bed':
                return self.do_bed(args)
            elif command == 'binit':
                return self.do_binit(args)
            elif command == 'bls':
                return self.do_bls()
            elif command == 'brun':
                return self.do_brun(args)
            else:
                print('Invalid Shython command: {}'.format(input_received))
                return ''

        return input_received

    def _next_help_command(self):
        """Gets next command from help-description list."""
        for (command, arg_desc, command_desc) in self.help_descriptions:
            yield command
        raise StopIteration

    def _next_help_description(self):
        """Gets next command description from help-description list."""
        for desc in self._help_descriptions_iterator():
            yield desc
        raise StopIteration

    def _get_help_description(self, command):
        """Gets specified command description from help-description list."""
        for (cur_command, arg_desc, command_desc) in self.help_descriptions:
            if command == cur_command:
                return (arg_desc, command_desc)
        return None

    def _format_help_description(self, command):
        """Formats command description for output."""
        SUBSEQUENT_INDENT = 20
        COMMAND_LEN_CUTOFF = 18

        desc = self._get_help_description(command)
        if desc is None:
            return None
        arg_desc, command_desc = desc

        shortcommand_desc_fmtr = textwrap.TextWrapper(subsequent_indent=' '*SUBSEQUENT_INDENT,
                                                 width=get_screen_width())
        longcommand_desc_fmtr = textwrap.TextWrapper(initial_indent=' '*SUBSEQUENT_INDENT,
                                                 subsequent_indent=' '*SUBSEQUENT_INDENT,
                                                 width=get_screen_width())

        formatted_command_line = '  {0} {1}'.format(command, arg_desc)
        if len(formatted_command_line) < COMMAND_LEN_CUTOFF:
            formatted_command_line = '{0:{1}}{2}'.format(formatted_command_line, SUBSEQUENT_INDENT, command_desc)
            formatted_desc = [shortcommand_desc_fmtr.fill(formatted_command_line),]
        else:
            formatted_desc_line = longcommand_desc_fmtr.fill(command_desc)
            formatted_desc = [formatted_command_line, formatted_desc_line]
        return formatted_desc

    @staticmethod
    def _count_traceback():
        """Assists with traceback if a buffer errors when executing."""
        tb = sys.exc_info()[2]

        count = 0
        while tb is not None:
            count += 1
            tb = tb.tb_next
        return count

    def _init_manager(self):
        """Initializes the buffer-file manager."""
        self.manager = BufferFileManager()

    def do_help(self, args):
        if len(args) > 0:
            command = args[0]
            formatted_desc = self._format_help_description(command)
            if formatted_desc is None:
                print('No help available for command \'{}\''.format(command))
                return ''
        else:
            formatted_desc = ['  {0:18}{1}'.format('COMMAND', 'DESCRIPTION')]
            for command in self._next_help_command():
                formatted_desc.extend(self._format_help_description(command))
        formatted_desc = '\n'.join(formatted_desc)
        print(formatted_desc)
        return ''

    def do_binit(self, args):
        self._init_manager()
        return ''

    def do_badd(self, args):
        if len(args) > 0:
            for path in args:
                self.manager.add_buffer(path=path)
        else:
            self.manager.add_buffer()
        return ''

    def do_bdel(self, args):
        if len(args) == 0:
            print('Must provide at least one buffer to delete')
        else:
            for buffer_number in args:
                if isinstance(buffer_number, str) is True:
                    buffer_number = int(buffer_number)
                self.manager.remove_buffer(buffer_number)
        return ''

    def do_bed(self, args):
        if len(args) == 0:
            print('Must provide at least one buffer to edit')
        else:
            new_args = list()
            for arg in args:
                if isinstance(arg, str) is True:
                    arg = int(arg)
                new_args.append(arg)
            self.manager.edit_buffers(new_args)
        return ''

    def do_brun(self, args):
        boundary = self._count_traceback()
        if len(args) == 0:
            print('Must provide at least one buffer to run')
        else:
            for buffer_number in args:
                if isinstance(buffer_number, str) is True:
                    buffer_number = int(buffer_number)

                buffer_path = self.manager.get_buffer_path(buffer_number)

                try:
                    # The goal of this functionality is that anything occuring in this
                    # buffer should take place at the interpreter's global scope. We
                    # override the traditional globals and locals because
                    # InteractiveConsole forces our hand. Since it maintains its
                    # own version of locals (that are actually globals), we must use
                    # that instead to acheive global scope.
                    if sys.version_info < (3,0):
                        execfile(buffer_path, self.locals, self.locals)
                    else:
                        with open(buffer_path, 'r') as buf:
                            exec(buf.read(), self.locals, self.locals)
                except:
                    # There is a reason for this universal except!! Anything occurring
                    # in the buffer should be considered non-fatal. Since the
                    # interpreter must continue, it is more useful to print the
                    # traceback (which includes the exception), rather than just
                    # the exception itself.

                    cur = self._count_traceback()

                    # The lowest traceback will be from within Shython. Remove it.
                    limit = cur - boundary - 1
                    if limit < 0:
                        limit = 0
                    traceback.print_exc(limit=limit)
        return ''

    def do_bls(self):
        self.manager.list_buffers()
        return ''

if __name__ == "__main__":
    if len(sys.argv) > 1:
        new_argv = sys.argv
        sys.argv[0] = sys.executable
        os.execv(sys.executable, new_argv)
    else:
        sh = ShythonConsole()
        sys.exit(sh.interact())
