#!/usr/bin/python3
'''
Rover is a text-based light-weight frontend for update-alternatives.
Copyright (C) 2018 Mo Zhou <lumin@debian.org>
License: GPL-3.0+
'''
from typing import *
import argparse
import re, sys, json
import subprocess
import termbox

__VERSION__ = '0.3.3'
__AUTHOR__ = 'Mo Zhou <lumin@debian.org>'

__DESIGN__ = '''
┌─────────────────────┬───────────────────────────────────────────────────────┐
│List of alternative  │List of available candidates for the symlink.          │
│names.               │                                                       │
│                     │                                                       │
│width: WIDTH*24/80   │width: full-width(lpane)-1                             │
│height: full-1       │height: full-1                                         │
│name: lpane          │name: rpane                                            │
│                     │                                                       │
│                     │                                                       │
│                     │                                                       │
│                     │                                                       │
│                     │                                                       │
│                     │                                                       │
│                     │                                                       │
│                     │                                                       │
│                     │                                                       │
│                     │                                                       │
│                     │                                                       │
│                     │                                                       │
│                     │                                                       │
├─────────────────────┴───────────────────────────────────────────────────────┤
│Status Bar. width: full, height: 1, name: status                             │
└─────────────────────────────────────────────────────────────────────────────┘
'''


def Version():
    '''
    Print version information to screen.
    '''
    print(f'Rover {__VERSION__}')
    exit(0)


def systemShell(command: List[str]) -> str:
    '''
    Execute the given command in system shell. Unlike os.system(),
    the program output to stdout and stderr will be returned.
    >>> systemShell(['ls', '-lh'])
    '''
    subp = subprocess.Popen(command,
            stdout=subprocess.PIPE, stderr=subprocess.PIPE)
    result = subp.communicate()[0].decode().strip()
    retcode = subp.returncode
    return result, retcode


def deb822parse(deb822: List[str]) -> List[Dict]:
    '''
    Parse DEB822-formatted text into a list of dictionaries.
    >>> lines = list(x.rstrip() for x in open('txt', 'r').readlines())
    >>> print(json.dumps(deb822parse(lines), indent=2))
    '''
    paragraphs = []
    def _deb822parse(lines: List[str], paras: List[Dict], key: str = None):
        if not lines:
            return paras
        elif re.match(r'^\w*:\s*.*$', lines[0]):
            if not paras: paras.append({})
            key, val = re.match(r'^(\w*):\s*(.*)\s*$', lines[0]).groups()
            paras[-1].update({key: ([val] if val else [])})
            return _deb822parse(lines[1:], paragraphs, key)
        elif re.match(r'^\s+.*$', lines[0]):
            if not paras or not key:
                raise SyntaxError("Malformed Input")
            val = re.match(r'^\s*(.*)\s*$', lines[0]).groups()[0]
            paras[-1][key].append(val)
            return _deb822parse(lines[1:], paragraphs, key)
        elif re.match(r'^\s*$', lines[0]):
            paras.append({})
            return _deb822parse(lines[1:], paragraphs, None)
        else:
            raise Exception("Internal Parser Error")
    _deb822parse(deb822, paragraphs)
    for d in paragraphs:
        for k, v in d.items():
            if isinstance(v, list) and len(v)==1:
                d[k] = v[0]
    return paragraphs


class UpAltAgent(object):
    '''
    A wrapper around update-alternatives.
    '''
    def get_selections(self, expr: str = None):
        '''
        "update-alternatives --get-selections" and apply custom filter.
        Note, each line contains three fields: (name, mode, alternative).
        The custom filter not only matches with name, but also mode
        and alternative. Which means you can search not only "blas",
        but also "manual", or "libmkl_rt".
        '''
        result, retcode = systemShell(
                ['update-alternatives', '--get-selections'])
        if retcode:
            return []
        elif expr is None:
            return [x.split() for x in result.split('\n')]
        else:
            try:
                matched = [x for x in result.split('\n')
                        if (expr.lower() in x.lower()) or re.match(expr, x)]
            except re.error as e:
                # The regular expression is invalid
                matched = [x for x in result.split('\n')
                        if expr.lower() in x.lower()]
            return [x.split() for x in matched]

    def query(self, name):
        '''
        execute "update-alternatives --query NAME" and parse the output.
        '''
        lines, code = systemShell(['update-alternatives', '--query', name])
        parsed = deb822parse(lines.split('\n'))
        info, candidates = parsed[0], parsed[1:]
        return info, candidates

    def set(self, name, selection):
        '''
        change alternatives setting
        '''
        msg, code = None, None
        if selection == 'auto':
            msg, code = systemShell(
                    ['update-alternatives', '--auto', name])
        else:
            msg, code = systemShell(
                    ['update-alternatives', '--set', name, selection])
        return msg, code


class Rover(object):
    '''
    Text-based light-weight frontend to update-alternatives.
    '''
    # Default color settings
    C = {
            'lpane_act': (termbox.WHITE, termbox.BLUE),
            'rpane_act': (termbox.WHITE, termbox.CYAN),
            'lpane_nor': (termbox.WHITE, termbox.BLACK),
            'rpane_nor': (termbox.WHITE, termbox.BLACK),
            'normal':(termbox.WHITE, termbox.BLACK),
            'st_normal': (termbox.WHITE | termbox.BOLD, termbox.BLACK),
            'st_error': (termbox.WHITE | termbox.BOLD, termbox.RED),
            'st_ok': (termbox.WHITE | termbox.BOLD, termbox.GREEN),
            'st_greet': (termbox.MAGENTA | termbox.BOLD, termbox.BLACK),
            'st_alert': (termbox.YELLOW | termbox.BOLD, termbox.BLACK),
            'background': (termbox.DEFAULT, termbox.BLACK)
    }
    C['st'] = C['st_greet']
    # The indeces of active items in lpane and rpane, respectively.
    lp_idx, rp_idx = 0, 0
    # Sliding windows for lpane and rpane
    lp_w, rp_w = [0, 0], [0, 0]
    # Misc
    status = f'Rover {__VERSION__} by {__AUTHOR__}'
    regex = ''

    def __init__(self, tb):
        '''
        set default values, and read the alternatives list
        tb: instantiated Termbox
        '''
        # Default Values
        self.tb = tb
        self.ua = UpAltAgent()
        self.refresh_selections()
        self.lp_w = [0, min(tb.height()-1, len(self.lp_list))]

        # read alternative list and initialize data for both panes
        self.lp_list = list(sorted(self.ua.get_selections()))
        self.rp_list = []
        self.parse_query()

    def status_hint(self):
        '''
        display keybinding hint in status bar
        '''
        h1 = 'HINT: [↓] z,n  [↑] w,p'.ljust(int(24*self.tb.width()/80))
        h1 += '| '
        h2 = '[↓] j,↓  [↑] k,↑  [*] SPACE,ENTER  [?] l,/  [X] q,ESC'
        h2 = h2.ljust(self.tb.width()-len(h1))
        self.status = h1 + h2
        self.C['st'] = self.C['st_alert']

    def refresh_selections(self):
        '''
        Reload "update-alternatives --get-selections"
        And filter contents in the left side pane. You can use either
        substring match or regex to match alternative names.
        '''
        lp_list = self.ua.get_selections(self.regex)
        if len(lp_list) == 0:
            self.status = 'Invalid filter expression!'
            self.C['st'] = self.C['st_error']
            return
        self.lp_list = lp_list
        if len(self.lp_list) <= self.lp_idx:
            self.lp_idx = 0
            self.lp_w = [0, tb.height()-1]
        self.lp_w = [0, min(tb.height()-1, len(self.lp_list))]

    def parse_query(self):
        '''
        parse the query result of UpAltAgent.query(...)
        '''
        name, mode, selection = self.lp_list[self.lp_idx]
        info, candidates = self.ua.query(name)
        # prepare contents for rpane
        rpl = []
        if 'auto' == info['Status']:
            rpl.append('[*] auto')
            self.rp_idx = 0
        else:
            rpl.append('[ ] auto')
        for i, cand in enumerate(candidates):
            alt, pri = cand['Alternative'], cand['Priority']
            if alt == info['Value'] and 'auto' != info['Status']:
                rpl.append('[*]' + f' {pri} {alt}')
                self.rp_idx = i + 1
            else:
                rpl.append('[ ]' + f' {pri} {alt}')
        self.rp_list = rpl
        self.rp_w = [0, min(self.tb.height()-1, len(rpl))]

    def draw(self):
        '''
        Draw the whole region
        '''
        # Fill the whole screen in black
        for i in range(self.tb.height()):
            for j in range(self.tb.width()):
                self.tb.change_cell(j, i, ord(' '), *self.C['background'])
        # Draw lpane
        choices = list(enumerate(self.lp_list))[self.lp_w[0]:self.lp_w[1]]
        lp_off, lp_w = 0, int(24*self.tb.width()/80)
        for i, (j, choice) in enumerate(choices):
            c = self.C['lpane_act'] if (j == self.lp_idx) else self.C['normal']
            name, mode, value = choice
            for k in range(lp_w):
                ch = name[k] if k < len(name) else ' '
                self.tb.change_cell(lp_off + k, i, ord(ch), *c)
        # Draw rpane
        choices = list(enumerate(self.rp_list))[self.rp_w[0]:self.rp_w[1]]
        rp_off, rp_w = lp_off+lp_w+1, self.tb.width()-lp_off-1
        for i, (j, line) in enumerate(choices):
            c = self.C['rpane_act'] if (j == self.rp_idx) else self.C['normal'] 
            for k in range(rp_w):
                ch = line[k] if k < len(line) else ' '
                self.tb.change_cell(rp_off + k, i, ord(ch), *c)
        # Draw Statusbar
        st_off, st_w = 0, self.tb.width()
        for k in range(st_w):
            ch = self.status[k] if k < len(self.status) else ' '
            self.tb.change_cell(st_off + k, self.tb.height()-1, ord(ch), *self.C['st'])
        # reset color of status bar
        self.C['st'] = self.C['st_normal']

    def lp_move_up(self):
        self.lp_idx = max(0, self.lp_idx - 1)
        if self.lp_idx < self.lp_w[0]:
            self.lp_w = list(map(lambda x: x-1, self.lp_w))
        self.status = ' | '.join(self.lp_list[self.lp_idx])
        self.parse_query()

    def lp_move_dn(self):
        self.lp_idx = min(len(self.lp_list)-1, self.lp_idx + 1)
        if self.lp_idx >= self.lp_w[1]:
            self.lp_w = list(map(lambda x: x+1, self.lp_w))
        self.status = ' | '.join(self.lp_list[self.lp_idx])
        self.parse_query()

    def rp_move_up(self):
        self.rp_idx = max(0, self.rp_idx - 1)
        if self.rp_idx < self.rp_w[0]:
            self.rp_w = list(map(lambda x: x-1, self.rp_w))
        selection = self.rp_list[self.rp_idx]
        self.status = f'*? {selection}'
        self.C['st'] = self.C['st_alert']

    def rp_move_dn(self):
        self.rp_idx = min(len(self.rp_list)-1, self.rp_idx + 1)
        if self.rp_idx >= self.rp_w[1]:
            self.rp_w = list(map(lambda x: x+1, self.rp_w))
        selection = self.rp_list[self.rp_idx]
        self.status = f'*? {selection}'
        self.C['st'] = self.C['st_alert']

    def set(self):
        '''
        change alternatives setting via UpAltAgent.set(...)
        '''
        name = self.lp_list[self.lp_idx][0]
        sele = self.rp_list[self.rp_idx].split()[-1]
        msg, code = self.ua.set(name, sele)
        if code == 2:
            self.status = f'Permission Denied. Are you root?'
            self.C['st'] = self.C['st_error']
        else:
            self.status = f'{name} -> {sele}'
            self.C['st'] = self.C['st_ok']
            self.ua.get_selections()
        self.parse_query()


if __name__ == '__main__':

    ag = argparse.ArgumentParser()
    ag.add_argument('-e', '--expression', type=str, default=None,
            help='Only display matched alternative names')
    ag.add_argument('-v', '--version', action='store_true',
            help='Print version information')
    ag = ag.parse_args()

    if ag.version:
        Version()

    with termbox.Termbox() as tb:
        tb.clear()
        rv = Rover(tb)

        if ag.expression:
            rv.regex = ag.expression
            rv.refresh_selections()
            rv.parse_query()

        rv.draw()
        tb.present()

        state_running = True
        state_input = False
        while state_running:
            ev = tb.poll_event()
            while ev:
                (typ, ch, key, mod, w, h, x, y) = ev
                # quit
                if (typ, key) == (termbox.EVENT_KEY, termbox.KEY_ESC):
                    state_running = False
                # quit
                elif ch == 'q' and not state_input:
                    state_running = False
                # right: down
                elif ch == 'j' and not state_input:
                    rv.rp_move_dn()
                # right: down
                elif (typ, key) == (termbox.EVENT_KEY, termbox.KEY_ARROW_DOWN):
                    rv.rp_move_dn()
                # right: up
                elif ch == 'k' and not state_input:
                    rv.rp_move_up()
                # right: up
                elif (typ, key) == (termbox.EVENT_KEY, termbox.KEY_ARROW_UP):
                    rv.rp_move_up()
                # left: down
                elif (typ, key) == (termbox.EVENT_KEY, termbox.KEY_CTRL_N):
                    rv.lp_move_dn()
                # left: down
                elif ch in ('n', 'z') and not state_input:
                    rv.lp_move_dn()
                # left: up
                elif ch in ('p', 'w') and not state_input:
                    rv.lp_move_up()
                # left: up
                elif (typ, key) == (termbox.EVENT_KEY, termbox.KEY_CTRL_P):
                    rv.lp_move_up()
                # trigger regex input box
                elif ch in ('l', '/') and not state_input:
                    state_input = True
                    rv.status = '?> '
                    rv.regex = ''
                    rv.C['st'] = rv.C['st_greet']
                # 2 cases for Enter key
                elif (typ, key) == (termbox.EVENT_KEY, termbox.KEY_ENTER):
                    if state_input:
                        # end regex input
                        state_input = False
                        rv.refresh_selections()
                        rv.parse_query()
                    else:
                        # trigger udpate-alternatives update
                        rv.set()
                # trigger alternatives update
                elif (typ, key) == (termbox.EVENT_KEY, termbox.KEY_SPACE):
                    if not state_input:
                        rv.set()
                # add character to regex input box
                elif state_input and ch:
                    rv.status += ch
                    rv.regex += ch
                    rv.C['st'] = rv.C['st_greet']
                # delete character in regex input box
                elif state_input and termbox.KEY_BACKSPACE:
                    rv.regex = rv.regex[:-1]
                    rv.status = '?> ' + rv.regex
                    rv.C['st'] = rv.C['st_greet']
                # status: display keybinding hint
                elif not state_input and ch == 'h':
                    rv.status_hint()
                # don't know what to do. Hint the user about usage.
                else:
                    rv.status_hint()
                ev = tb.peek_event()
            # refresh screen
            tb.clear()
            rv.draw()
            tb.present()
