class Sentinel(object):

    """
    Sentinel class
    Manages scanner terminals
    """

    def __init__(self, stdscr):
        """
        Initialize the sentinel program
        """
        # Read user configuration
        config = ConfigParser.SafeConfigParser(DEFAULT_CONFIG)
        self.datadir = os.path.expanduser("~/")
        config.read([
            '.oerp_sentinelrc',
            '.openerp_sentinelrc',
            '.odoo_sentinelrc',
            os.path.join(self.datadir, '.oerp_sentinelrc'),
            os.path.join(self.datadir, '.openerp_sentinelrc'),
            os.path.join(self.datadir, '.odoo_sentinelrc'),
        ])

        # No configfile found, exit
        if 'openerp' not in config.sections():
            raise Exception('Config Error', 'Config file not found !')

        # Connection to the OpenERP Server
        self.connection = Connection(
            server=config.get('openerp', 'host'),
            dbname=config.get('openerp', 'database'),
            login=config.get('openerp', 'user'),
            password=config.get('openerp', 'password'),
            port=config.get('openerp', 'port'),
        )

        # Open the test file, if any
        test_file_name = config.get('openerp', 'test_file')
        self.test_file = None
        if test_file_name:
            self.test_file = open(test_file_name, 'r')

        # Initialize translations
        self.context = Object(self.connection, 'res.users').context_get()
        lang = self.context.get('lang', I18N_DEFAULT)
        gettext.install(I18N_DOMAIN)
        try:
            language = gettext.translation(
                I18N_DOMAIN, I18N_DIR, languages=[lang])
        except:
            language = gettext.translation(
                I18N_DOMAIN, I18N_DIR, languages=[I18N_DEFAULT])

        # Replace global dummy lambda by the translations gettext method
        # The install method of gettext doesn't replace the function if exists
        global _
        _ = language.gettext

        # Initialize hardware
        self.hardware_obj = Object(self.connection, 'scanner.hardware')
        self.scenario_obj = Object(self.connection, 'scanner.scenario')

        # Initialize window
        self.screen = stdscr
        self._set_screen_size()

        self._init_colors()

        # Get the informations for this material from server (identified by IP)
        self.hardware_code = ''
        self.scenario_id = False
        self.scenario_name = False
        try:
            ssh_data = os.environ['SSH_CONNECTION'].split(' ')
            self.hardware_code = ssh_data[0]
            self.scanner_check()
        except:
            self.hardware_code = self._input_text(
                _('Autoconfiguration failed !\nPlease enter terminal code'))
            self.scanner_check()

        # Resize window to terminal screen size
        self._resize()

        # Reinit colors with values configured in OpenERP
        self._reinit_colors()

        # Initialize mouse events capture
        curses.mousemask(
            curses.BUTTON1_CLICKED | curses.BUTTON1_DOUBLE_CLICKED)

        # Reinitialize to the main menu when using a test file (useful when
        # the last run has crashed before end)
        if test_file_name:
            self.oerp_call('end')

        # Load the sentinel
        self.main_loop()

    def scanner_check(self):
        self.scenario_id = self.hardware_obj.scanner_check(
            self.hardware_code, self.context)
        if isinstance(self.scenario_id, list):
            self.scenario_id, self.scenario_name = self.scenario_id

    def _resize(self):
        """
        Resizes the window
        """
        # Asks for the hardware screen size
        (width, height) = self.oerp_call('screen_size')[1]
        self._set_screen_size(width, height)

    def _init_colors(self):
        """
        Initialize curses colors
        """
        # Declare all configured color pairs for curses
        for (the_id, front_color, back_color) in COLOR_PAIRS.values():
            curses.init_pair(
                the_id, COLOR_NAMES[front_color], COLOR_NAMES[back_color])

        # Set the default background color
        self.screen.bkgd(0, self._get_color('base'))

    def _reinit_colors(self):
        """
        Initializes the colors from Odoo configuration
        """
        # Asks for the hardware screen size
        colors = self.oerp_call('screen_colors')[1]
        COLOR_PAIRS['base'] = (1, colors['base'][0], colors['base'][1])
        COLOR_PAIRS['info'] = (2, colors['info'][0], colors['info'][1])
        COLOR_PAIRS['error'] = (3, colors['error'][0], colors['error'][1])
        self._init_colors()

    def _set_screen_size(self, width=18, height=6):
        self.window_width = width
        self.window_height = height
        self.screen.resize(height, width)

    def _get_color(self, name):
        """
        Get a curses color's code
        """
        return curses.color_pair(COLOR_PAIRS[name][0])

    def _read_from_file(self):
        """
        Emulates the getkey method of curses, reading from the supplied test
        file
        """
        key = self.test_file.read(1)
        if key == ':':
            # Truncate the trailing "new line" character
            key = self.test_file.readline()[:-1]

        # End of file reached, terminate the sentinel
        if not key:
            self.test_file.close()
            exit(0)

        return key

    def ungetch(self, value):
        """
        Put a value in the keyboard buffer
        """
        curses.ungetch(value)

    def getkey(self):
        """
        Get a user input and avoid Ctrl+C
        """
        if self.test_file:
            # Test file supplied, read from it
            key = self._read_from_file()
        else:
            # Get the pushed character
            key = self.screen.getkey()
        if key == '':
            # Escape key : Return back to the previous step
            raise SentinelBackException('Back')
        return key

    def _display(self, text='', x=0, y=0, clear=False, color='base',
                 bgcolor=False, modifier=curses.A_NORMAL, cursor=None,
                 height=None, scroll=False, title=None):
        """
        Display a line of text
        """
        # Clear the sceen if needed
        if clear:
            self.screen.clear()

        # Display the title, if any
        if title is not None:
            y += 1
            title = title.center(self.window_width)
            self._display(
                title, color='info',
                modifier=curses.A_REVERSE | curses.A_BOLD)

        # Compute the display modifiers
        color = self._get_color(color) | modifier
        # Set background to 'error' colors
        if bgcolor:
            self.screen.bkgd(0, color)

        # Normalize the text, because ncurses doesn't know UTF-8 with
        # python 2.x
        if isinstance(text, str):
            text = text.decode('utf-8')
        text = unicodedata.normalize(
            'NFKD', unicode(text)).encode('ascii', 'ignore')
        text = ''.join(
            [char not in ('\r', '\n') and
             curses.ascii.unctrl(char) or char for char in text])

        # Display the text
        if not scroll:
            self.screen.addstr(y, x, text, color)
        else:
            # Wrap the text to avoid splitting words
            text_lines = []
            for line in text.splitlines():
                text_lines.extend(
                    textwrap.wrap(line, self.window_width - x - 1) or [''])

            # Initialize variables
            first_line = 0
            if height is None:
                height = self.window_height

            (cursor_y, cursor_x) = cursor or (
                self.window_height - 1, self.window_width - 1)

            while True:
                # Display the menu
                self.screen.addstr(height - 1, x,
                                   (self.window_width - x - 1) * ' ', color)
                self.screen.addstr(
                    y, x, '\n'.join(
                        text_lines[first_line:first_line + height - y]),
                    color)

                # Display arrows
                if first_line > 0:
                    self.screen.addch(
                        y, self.window_width - 1, curses.ACS_UARROW)
                if first_line + height < len(text_lines):
                    self.screen.addch(
                        min(height + y - 1, self.window_height - 2),
                        self.window_width - 1, curses.ACS_DARROW)
                else:
                    self.screen.addch(
                        min(height + y - 1, self.window_height - 2),
                        self.window_width - 1, ' ')

                # Set the cursor position
                if height < len(text_lines):
                    scroll_height = len(text_lines) - height
                    position_percent = float(first_line) / scroll_height
                    position = y + min(
                        int(round((height - 1) * position_percent)),
                        self.window_height - 2)
                    self._display(
                        ' ', x=self.window_width - 1, y=position - 1,
                        color='info', modifier=curses.A_REVERSE)
                self.screen.move(cursor_y, cursor_x)

                # Get the pushed key
                key = self.getkey()

                if key == 'KEY_DOWN':
                    # Down key : Go down in the list
                    first_line += 1
                elif key == 'KEY_UP':
                    # Up key : Go up in the list
                    first_line -= 1
                else:
                    # Return the pressed key value
                    return key

                # Avoid going out of the list
                first_line = min(
                    max(0, first_line), max(0, len(text_lines) - height + 1))

    def main_loop(self):
        """
        Loops until the user asks for ending
        """
        code = False
        result = None
        value = None

        while True:
            try:
                try:
                    # No active scenario, select one
                    if not self.scenario_id:
                        (code, result, value) = self._select_scenario()
                    else:
                        # Search for a step title
                        title = None
                        title_key = '|'
                        if isinstance(result, (types.NoneType, bool)):
                            pass
                        elif (isinstance(result, dict) and
                              result.get(title_key, None)):
                            title = result[title_key]
                            del result[title_key]
                        elif (isinstance(result[0], (tuple, list)) and
                              result[0][0] == title_key):
                            title = result.pop(0)[1]
                        elif (isinstance(result[0], basestring) and
                              result[0].startswith(title_key)):
                            title = result.pop(0)[len(title_key):]

                        if title is None and self.scenario_name:
                            # If no title is defined, display the scenario name
                            title = self.scenario_name

                        if code == 'Q' or code == 'N':
                            # Quantity selection
                            quantity = self._select_quantity(
                                '\n'.join(result), '%g' % value,
                                integer=(code == 'N'), title=title)
                            (code, result, value) = self.oerp_call('action',
                                                                   quantity)
                        elif code == 'C':
                            # Confirmation query
                            confirm = self._confirm(
                                '\n'.join(result), title=title)
                            (code, result, value) = self.oerp_call('action',
                                                                   confirm)
                        elif code == 'T':
                            # Select arguments from value
                            default = ''
                            size = None
                            if isinstance(value, dict):
                                default = value.get('default', '')
                                size = value.get('size', None)
                            elif isinstance(value, str):
                                default = value

                            # Text input
                            text = self._input_text(
                                '\n'.join(result), default=default,
                                size=size, title=title)
                            (code, result, value) = self.oerp_call('action',
                                                                   text)
                        elif code == 'R':
                            # Critical error
                            self.scenario_id = False
                            self.scenario_name = False
                            self._display_error('\n'.join(result), title=title)
                        elif code == 'U':
                            # Unknown action : message with return back to the
                            # last state
                            self._display(
                                '\n'.join(result), clear=True, scroll=True,
                                title=title)
                            (code, result, value) = self.oerp_call('back')
                        elif code == 'E':
                            # Error message
                            self._display_error(
                                '\n'.join(result), title=title)
                            # Execute transition
                            if not value:
                                (code, result, value) = self.oerp_call(
                                    'action')
                            else:
                                # Back to the previous step required
                                (code, result, value) = self.oerp_call(
                                    'back')
                        elif code == 'M':
                            # Simple message
                            self._display(
                                '\n'.join(result), clear=True, scroll=True,
                                title=title)
                            # Execute transition
                            (code, result, value) = self.oerp_call('action',
                                                                   value)
                        elif code == 'L':
                            if result:
                                # Select a value in the list
                                choice = self._menu_choice(result, title=title)
                                # Send the result to Odoo
                                (code, result, value) = self.oerp_call(
                                    'action', choice)
                            else:
                                # Empty list supplied, display an error
                                (code, result, value) = (
                                    'E', [_('No value available')], True)

                            # Check if we are in a scenario (to retrieve the
                            # scenario name from a submenu)
                            self.scanner_check()
                            if not self.scenario_id:
                                self.scenario_id = True
                                self.scenario_name = False
                        elif code == 'F':
                            # End of scenario
                            self.scenario_id = False
                            self.scenario_name = False
                            self._display('\n'.join(result), clear=True,
                                          scroll=True, title=title)
                        else:
                            # Default call
                            (code, result, value) = self.oerp_call('restart')
                except SentinelBackException:
                    # Back to the previous step required
                    (code, result, value) = self.oerp_call('back')
                    # Do not display the termination message
                    if code == 'F':
                        self.ungetch(ord('\n'))
                    self.screen.bkgd(0, self._get_color('base'))
                except Exception:
                    # Generates log contents
                    log_contents = """%s
# %s
# Hardware code : %s
# ''Current scenario : %s (%s)
# Current values :
#\tcode : %s
#\tresult : %s
#\tvalue : %s
%s
%s
"""
                    log_contents = log_contents % (
                        '#' * 79, datetime.now().strftime('%Y-%m-%d %H:%M:%S'),
                        self.hardware_code, str(self.scenario_id),
                        self.scenario_name, code, repr(result), repr(value),
                        '#' * 79, reduce(
                            lambda x, y: x + y, traceback.format_exception(
                                sys.exc_info()[0],
                                sys.exc_info()[1],
                                sys.exc_info()[2])))

                    # Writes traceback in log file
                    logfile = open(self.datadir + 'oerp_sentinel.log', 'a')
                    logfile.write(log_contents)
                    logfile.close()

                    # Display error message
                    (code, result, value) = (
                        'E', [_('An error occured\n\nPlease contact your '
                                'administrator')], False)
            except KeyboardInterrupt:
                # If Ctrl+C, exit
                (code, result, value) = self.oerp_call('end')
                # Restore normal background colors
                self.screen.bkgd(0, self._get_color('base'))

    def _display_error(self, error_message, title=None):
        """
        Displays an error message, changing the background to red
        """
        # Display error message
        self._display(error_message, color='error', bgcolor=True, clear=True,
                      scroll=True, title=title)
        # Restore normal background colors
        self.screen.bkgd(0, self._get_color('base'))

    def oerp_call(self, action, message=False):
        """
        Calls a method from Odoo Server
        """
        return self.hardware_obj.scanner_call(
            self.hardware_code, action, message, 'keyboard', self.context)

    def _select_scenario(self):
        """
        Selects a scenario from the server
        """
        # Get the scenarios list from server
        values = self.oerp_call('menu')[1]

        # If no scenario available : return an error
        if not values:
            return ('R', [_('No scenario available !')], 0)

        # Select a scenario in the list
        choice = self._menu_choice(values, title=_('Scenarios'))
        ret = self.oerp_call('action', choice)

        # Store the scenario id and name
        self.scanner_check()
        if not self.scenario_id:
            self.scenario_id = True
            self.scenario_name = False

        # Send the result to OpenERP
        return ret

    def _confirm(self, message, title=None):
        """
        Allows the user to select quantity
        """
        confirm = False

        while True:
            # Clear the screen
            self._display(clear=True)

            # Compute Yes/No positions
            yes_start = 0
            yes_padding = int(math.floor(self.window_width / 2))
            yes_text = _('Yes').center(yes_padding)
            no_start = yes_padding
            no_padding = self.window_width - no_start - 1
            no_text = _('No').center(no_padding)

            if confirm:
                # Yes selected
                yes_modifier = curses.A_BOLD | curses.A_REVERSE
                no_modifier = curses.A_NORMAL
            else:
                # No selected
                yes_modifier = curses.A_NORMAL
                no_modifier = curses.A_BOLD | curses.A_REVERSE

            # Display Yes
            self._display(yes_text, x=yes_start, y=self.window_height - 1,
                          color='info', modifier=yes_modifier)
            # Display No
            self._display(no_text, x=no_start, y=self.window_height - 1,
                          color='info', modifier=no_modifier)

            # Display the confirmation message
            key = self._display(message, scroll=True,
                                height=self.window_height - 1, title=title)

            if key == '\n':
                # Return key : Validate the choice
                return confirm
            elif (key == 'KEY_DOWN' or
                  key == 'KEY_LEFT' or
                  key == 'KEY_UP' or
                  key == 'KEY_RIGHT'):
                # Arrow key : change value
                confirm = not confirm
            elif key.upper() == 'O' or key.upper() == 'Y':
                # O (oui) or Y (yes)
                confirm = True
            elif key.upper() == 'N':
                # N (No)
                confirm = False
            elif key == 'KEY_MOUSE':
                # Retrieve mouse event information
                mouse_info = curses.getmouse()

                # Set the selected entry
                confirm = mouse_info[1] < len(yes_text)

                # If we double clicked, auto-validate
                if mouse_info[4] & curses.BUTTON1_DOUBLE_CLICKED:
                    return confirm

    def _input_text(self, message, default='', size=None, title=None):
        """
        Allows the user to input random text
        """
        # Initialize variables
        value = default
        line = self.window_height - 1
        self.screen.move(line, 0)
        # Flush the input
        curses.flushinp()

        # While we do not validate, store characters
        while True:
            # Clear the screen
            self._display(clear=True)

            # Display the current value if echoing is needed
            display_value = ''.join(
                [curses.ascii.unctrl(char) for char in value])
            display_start = max(0, len(display_value) - self.window_width + 1)
            display_value = display_value[display_start:]
            self._display(' ' * (self.window_width - 1), 0, line)
            self._display(
                display_value, 0, line, color='info', modifier=curses.A_BOLD)
            key = self._display(
                message, scroll=True, height=self.window_height - 1,
                cursor=(line, min(len(value), self.window_width - 1)),
                title=title)

            # Printable character : store in value
            if len(key) == 1 and (curses.ascii.isprint(key) or ord(key) < 32):
                value += key
            # Backspace or del, remove the last character
            elif key == 'KEY_BACKSPACE' or key == 'KEY_DC':
                value = value[:-1]

            # Move cursor at end of the displayed value
            if key == '\n' or (size is not None and len(value) >= size):
                # Flush the input
                curses.flushinp()
                return value.strip()

    def _select_quantity(self, message, quantity='0', integer=False,
                         title=None):
        """
        Allows the user to select  quantity
        """
        # Erase the selected quantity on the first digit key press
        digit_key_pressed = False

        while True:
            # Clear the screen
            self._display(clear=True)
            # Diplays the selected quantity
            self._display(
                _('Selected : %s') % quantity, y=self.window_height - 1,
                color='info', modifier=curses.A_BOLD)

            # Display the message and get the key
            key = self._display(
                message, scroll=True, height=self.window_height - 1,
                title=title)

            if key == '\n':
                # Return key : Validate the choice
                return float(quantity)
            elif key.isdigit():
                if not digit_key_pressed:
                    quantity = '0'
                    digit_key_pressed = True

                # Digit : Add at end
                if quantity == '0':
                    quantity = key
                else:
                    quantity += key
            elif (not integer and
                  '.' not in quantity and
                  (key == '.' or key == ',' or key == '*')):
                # Decimal point
                quantity += '.'
            elif key == 'KEY_BACKSPACE' or key == 'KEY_DC':
                # Backspace : Remove last digit
                quantity = quantity[:-1]
                digit_key_pressed = True
            elif key == 'KEY_DOWN' or key == 'KEY_LEFT':
                # Down key : Decrease
                quantity = '%g' % (float(quantity) - 1)
            elif key == 'KEY_UP' or key == 'KEY_RIGHT':
                # Up key : Increase
                quantity = '%g' % (float(quantity) + 1)

            if not quantity:
                quantity = '0'

    def _menu_choice(self, entries, title=None):
        """
        Allows the user to choose a value in a list
        """
        # If a dict is passed, keep the keys
        keys = entries
        if isinstance(entries, dict):
            keys, entries = entries.items()
        elif isinstance(entries[0], (tuple, list)):
            keys, entries = map(list, zip(*entries))[:2]

        # Highlighted entry
        highlighted = 0
        first_column = 0
        max_length = max([len(value) for value in entries])

        # Add line numbers before text
        display = []
        index = 0
        nb_char = int(math.floor(math.log10(len(entries))) + 1)
        decal = nb_char + 3
        for value in entries:
            display.append(
                '%s: %s' % (str(index).rjust(nb_char),
                            value[:self.window_width - decal]))
            index += 1

        while True:
            # Display the menu
            self._menu_display(display, highlighted, title=title)

            # Get the pushed key
            key = self.getkey()
            digit_key = False

            if key == '\n':
                # Return key : Validate the choice
                return keys[highlighted]
            elif key.isdigit():
                # Digit : Add at end of index
                highlighted = highlighted * 10 + int(key)
                digit_key = True
            elif key == 'KEY_BACKSPACE' or key == 'KEY_DC':
                # Backspace : Remove last digit from index
                highlighted = int(math.floor(highlighted / 10))
            elif key == 'KEY_DOWN':
                # Down key : Go down in the list
                highlighted = highlighted + 1
            elif key == 'KEY_RIGHT':
                # Move display
                first_column = max(
                    0, min(first_column + 1,
                           max_length - self.window_width + decal))
                display = []
                index = 0
                for value in entries:
                    display.append(
                        '%s: %s' % (
                            str(index).rjust(nb_char),
                            value[first_column:
                                  self.window_width - decal + first_column]))
                    index += 1
            elif key == 'KEY_UP':
                # Up key : Go up in the list
                highlighted = highlighted - 1
            elif key == 'KEY_LEFT':
                # Move display
                first_column = max(0, first_column - 1)
                display = []
                index = 0
                for value in entries:
                    display.append(
                        '%s: %s' % (
                            str(index).rjust(nb_char),
                            value[first_column:
                                  self.window_width - decal + first_column]))
                    index += 1
            elif key == 'KEY_MOUSE':
                # First line to be displayed
                first_line = 0
                nb_lines = self.window_height - 1
                middle = int(math.floor(nb_lines / 2))

                # Change the first line if there is too much lines for the
                # screen
                if len(entries) > nb_lines and highlighted >= middle:
                    first_line = min(highlighted - middle,
                                     len(entries) - nb_lines)

                # Retrieve mouse event information
                mouse_info = curses.getmouse()

                # Set the selected entry
                highlighted = min(max(0, first_line + mouse_info[2]),
                                  len(entries) - 1)

                # If we double clicked, auto-validate
                if mouse_info[4] & curses.BUTTON1_DOUBLE_CLICKED:
                    return keys[highlighted]

            # Avoid going out of the list
            highlighted %= len(entries)

            # Auto validate if max number is reached
            current_nb_char = int(
                math.floor(math.log10(max(1, highlighted))) + 1)
            if highlighted and digit_key and current_nb_char >= nb_char:
                return keys[highlighted]

    def _menu_display(self, entries, highlighted, title=None):
        """
        Display a menu, highlighting the selected entry
        """
        # First line to be displayed
        first_line = 0
        nb_lines = self.window_height - 1
        if len(entries) > nb_lines:
            nb_lines -= 1
        middle = int(math.floor((nb_lines - 1) / 2))
        # Change the first line if there is too much lines for the screen
        if len(entries) > nb_lines and highlighted >= middle:
            first_line = min(highlighted - middle, len(entries) - nb_lines)

        # Display all entries, normal display
        self._display('\n'.join(entries[first_line:first_line + nb_lines]),
                      clear=True, title=title)
        # Highlight selected entry
        self._display(
            entries[highlighted].ljust(self.window_width - 1),
            y=highlighted - first_line,
            modifier=curses.A_REVERSE | curses.A_BOLD, title=title)

        # Display arrows
        if first_line > 0:
            self.screen.addch(0, self.window_width - 1, curses.ACS_UARROW)
        if first_line + nb_lines < len(entries):
            self.screen.addch(
                nb_lines, self.window_width - 1, curses.ACS_DARROW)

        # Diplays number of the selected entry
        self._display(_('Selected : %d') % highlighted, y=self.window_height-1,
                      color='info', modifier=curses.A_BOLD)

        # Set the cursor position
        if nb_lines < len(entries):
            position_percent = float(highlighted) / len(entries)
            position = int(round(nb_lines * position_percent))
            self._display(
                ' ', x=self.window_width - 1, y=position, color='info',
                modifier=curses.A_REVERSE)
        self.screen.move(self.window_height - 1, self.window_width - 1)