#!/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.74'
__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/2)-1                                   │
│name: lpane          │name: rpane                                            │
│                     │                                                       │
│                     │                                                       │
│                     │                                                       │
│                     │                                                       │
│                     ├───────────────────────────────────────────────────────┤
│                     │Details about the highlighted candidate, incl. slaves. │
│                     │name: ipane                                            │
│                     │                                                       │
│                     │                                                       │
│                     │                                                       │
│                     │                                                       │
│                     │                                                       │
│                     │                                                       │
├─────────────────────┴───────────────────────────────────────────────────────┤
│Status Bar. width: full, height: 1, name: status                             │
└─────────────────────────────────────────────────────────────────────────────┘
'''

tb = termbox.Termbox


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 AbstractWidget(object):
    '''
    Abstract Class for the TUI widgets.
    '''

    def __init__(self):
        self.focused = False

    def set(self, name, value):
        raise NotImplementedError

    def get(self, name):
        raise NotImplementedError

    def focus(self, state=None):
        raise NotImplementedError

    def draw(self):
        raise NotImplementedError


class StatusBar(AbstractWidget):
    '''
    Status bar.
    '''

    def __init__(self):
        self.location = [0, 0]  # (y,x)
        self.length = [0]  # int
        self.msg = ''

        self.C_nor = (termbox.WHITE | termbox.BOLD, termbox.BLACK)
        self.C_err = (termbox.WHITE | termbox.BOLD, termbox.RED)
        self.C_suc = (termbox.WHITE | termbox.BOLD, termbox.GREEN)
        self.C_vio = (termbox.MAGENTA | termbox.BOLD, termbox.BLACK)
        self.C_wrn = (termbox.YELLOW | termbox.BOLD, termbox.BLACK)
        self.C = self.C_nor  # default color

    def reset(self, location, length):
        self.location = location
        self.length = length
        self.msg = ''
        self.C = self.C_nor
        self.draw()

    def reloc(self, location, length):
        self.location, self.length = location, length
        self.draw()

    def nor(self, msg):
        self.C, self.msg = self.C_nor, msg
        self.draw()

    def err(self, msg):
        self.C, self.msg = self.C_err, msg
        self.draw()

    def suc(self, msg):
        self.C, self.msg = self.C_suc, msg
        self.draw()

    def vio(self, msg):
        self.C, self.msg = self.C_vio, msg
        self.draw()

    def wrn(self, msg):
        self.C, self.msg = self.C_wrn, msg
        self.draw()

    def draw(self, msg=None):
        for k in range(self.length):
            ch = self.msg[k] if k < len(self.msg) else ' '
            tb.change_cell(self.location[1] + k, self.location[0], ord(ch),
                           *self.C)


class SelectBox(AbstractWidget):
    '''
    Selection box.
    '''

    def __init__(self):
        self.focused = False
        self.location = [0, 0]  # (y,x)
        self.size = [0, 0]  # (h,w)
        self.choices = ['']  # list[str]
        self.frame = [0, 0]  # (f,t), internal
        self.cursor = 0  # int, internal

        self.C_cur = (termbox.WHITE, termbox.BLUE)
        self.C_nor = (termbox.WHITE, termbox.BLACK)
        self.C_idl = (termbox.BLUE | termbox.BOLD, termbox.BLACK)

    def reset(self, location, size, choices):
        self.location = location
        self.size = size
        self.choices = choices
        self.frame = [0, self.size[0]]
        self.cursor = 0
        self.draw()

    def set(self, name, value):
        if not hasattr(self, name):
            raise AttributeError
        setattr(self, name, value)
        if name == 'size':
            self.frame = [self.frame[0], self.frame[0] + self.size[0]]
            if self.cursor > self.frame[1] or self.cursor < self.frame[0]:
                self.cursor = self.frame[0]
        self.draw()

    def get(self, name=None):
        if name is None:
            return self.choices[self.cursor]
        if not hasattr(self, name):
            raise AttributeError
        return getattr(self, name)

    def focus(self, state=None):
        if state is None:
            return self.focused
        else:
            self.set('focused', state)

    def draw(self):
        choices = list(enumerate(self.choices))[self.frame[0]:self.frame[1]]
        for i in range(self.size[0]):
            if i >= len(choices):
                color = self.C_nor
                for j in range(self.size[1]):
                    tb.change_cell(self.location[1] + j, self.location[0] + i,
                                   ord(' '), *color)
            else:
                if choices[i][0] == self.cursor:
                    color = self.C_cur if self.focused else self.C_idl
                else:
                    color = self.C_nor
                for j in range(self.size[1]):
                    ch = choices[i][1][j] if j < len(choices[i][1]) else ' '
                    tb.change_cell(self.location[1] + j, self.location[0] + i,
                                   ord(ch), *color)

    def next(self):
        self.cursor = min(self.cursor + 1, len(self.choices) - 1)
        if self.cursor > self.frame[1] - 1:
            self.frame = [self.frame[0] + 1, self.frame[1] + 1]
        self.draw()

    def prev(self):
        self.cursor = max(self.cursor - 1, 0)
        if self.cursor < self.frame[0]:
            self.frame = [self.frame[0] - 1, self.frame[1] - 1]
        self.draw()


class Rover(object):
    '''
    Text-based light-weight frontend to update-alternatives.
    '''

    def __init__(self):
        '''
        set default values, and read the alternatives list
        tb: instantiated Termbox
        '''
        # Get U-A selections
        self.ua = UpAltAgent()
        self.selections = list(
            sorted(self.ua.get_selections(), key=lambda x: x[0]))

        # Create widgets
        self.st = StatusBar()
        self.lp = SelectBox()
        self.rp = SelectBox()
        self.ip = SelectBox()
        self.reloc()

        # Initialize widgets
        self.st.reset(self.ST_LOC, self.ST_LENGTH)
        self.st.vio(f'Rover {__VERSION__} by {__AUTHOR__}')
        self.lp.reset(self.LP_LOC, self.LP_SIZE,
                      list(sorted(x[0] for x in self.selections)))
        self.lp.focus(True)
        self.FOCUS = 'lp'
        self.rp.reset(self.RP_LOC, self.RP_SIZE, [''])
        self.ip.reset(self.IP_LOC, self.IP_SIZE, [''])

        # Parse the selection in left pane, then update right pane
        self.update_rp()
        self.update_ip()

    def reloc(self):
        self.ST_LOC = (tb.height() - 1, 0)
        self.ST_LENGTH = tb.width()
        self.LP_LOC = (0, 0)
        self.LP_SIZE = (tb.height() - 1, int(tb.width() * 24 / 80))
        self.RP_LOC = (0, self.LP_SIZE[1] + 1)
        self.RP_SIZE = (int(tb.height() * 1 / 2) - 1, tb.width() - self.LP_SIZE[0] - 1)
        self.IP_LOC = (int(tb.height() * 1 / 2), self.LP_SIZE[1] + 1)
        self.IP_SIZE = (int(tb.height() * 1 / 2) - 1, tb.width() - self.LP_SIZE[1] - 1)
        self.VS_LOC = (0, self.LP_SIZE[1])
        self.VS_HEIGHT = tb.height() - 1

        self.lp.set('location', self.LP_LOC)
        self.lp.set('size', self.LP_SIZE)
        self.rp.set('location', self.RP_LOC)
        self.rp.set('size', self.RP_SIZE)
        self.st.reloc(self.ST_LOC, self.ST_LENGTH)

    def status_hint(self):
        '''
        display keybinding hint in status bar
        '''
        msg = '[↓] j,↓  [↑] k,↑ [←] h,← [→] l,→ [*] SPACE,ENTER [?] /,? [X] q,ESC'
        self.st.wrn(msg)

    def reload_selections(self, regex=None):
        '''
        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.
        '''
        selections = list(
            sorted(self.ua.get_selections(regex), key=lambda x: x[0]))
        if len(selections) == 0:
            self.st.err('Invalid filter expression!')
            return
        self.selections = selections
        self.lp.reset(self.LP_LOC, self.LP_SIZE,
                      list(sorted(x[0] for x in self.selections)))

    def update_rp(self):
        '''
        Query the status of candidates, using the selection of left pane
        parse the query result of UpAltAgent.query(...)
        then update the right pane
        '''
        name, mode, selection = self.selections[self.lp.get('cursor')]
        assert (name == self.lp.get())
        #info, candidates = self.ua.query(self.lp.get())
        info, candidates = self.ua.query(name)
        # prepare contents for rpane
        padding = max(len(cand['Priority']) for cand in candidates)
        rpl, rp_cursor = [], 0
        if 'auto' == info['Status']:
            rpl.append(' '.join(('[*]', '?'.ljust(padding), 'auto')))
        else:
            rpl.append(' '.join(('[ ]', '?'.ljust(padding), 'auto')))
        for i, cand in enumerate(candidates, 1):
            alt, pri = cand['Alternative'], cand['Priority']
            if alt == info['Value'] and 'auto' != info['Status']:
                rpl.append('[*]' + f' {pri.ljust(padding)} {alt}')
                rp_cursor = i
            else:
                rpl.append('[ ]' + f' {pri.ljust(padding)} {alt}')
        self.rp.reset(self.RP_LOC, self.RP_SIZE, rpl)
        self.rp.set('cursor', rp_cursor)

    def update_ip(self):
        name, mode, selection = self.selections[self.lp.get('cursor')]
        assert (name == self.lp.get())
        info, candidates = self.ua.query(name)
        iselection = self.rp.get().split()[-1]
        # prepare contents for ipane
        ipl, ip_cursor = [], 0
        if iselection == 'auto':
            idict = [x for x in candidates if x['Alternative'] == selection][0]
        else:
            idict = [x for x in candidates if x['Alternative'] == iselection][0]
        ipl.extend(['Alternative', idict['Alternative'], ''])
        ipl.extend(['Priority: {}'.format(idict['Priority']), ''])
        slaves = idict.get('Slaves', [])
        if slaves:
            ipl.extend(['Slaves'])
            if isinstance(slaves, str):
                ipl.append(slaves)
            elif isinstance(slaves, list):
                ipl.extend(slaves)
        self.ip.reset(self.IP_LOC, self.IP_SIZE, ipl)

    def draw(self):
        '''
        Draw the whole region
        '''
        self.reloc()
        # Draw status bar, left pane, right pane
        self.st.draw()
        self.lp.draw()
        self.rp.draw()
        self.ip.draw()
        # Draw split lines, vertical and horizontal
        self.vs_draw()
        self.hs_draw()

    def vs_draw(self):
        for i in range(self.VS_HEIGHT):
            tb.change_cell(self.VS_LOC[1], i, ord('│'), termbox.WHITE,
                           termbox.BLACK)

    def hs_draw(self):
        for j in range(self.VS_LOC[1], tb.width()):
            ch = ord('├') if j == self.VS_LOC[1] else ord('─')
            tb.change_cell(j, int(tb.height() * 1 / 2)-1, ch, termbox.WHITE,
                           termbox.BLACK)

    def move_up(self):
        if self.FOCUS == 'lp':
            self.lp.prev()
            self.st.nor(' │ '.join(self.selections[self.lp.get('cursor')]))
            self.update_rp()
            self.update_ip()
        else:
            self.rp.prev()
            selection = self.rp.get()
            self.st.wrn(f'*? {selection}')
            self.update_ip()

    def move_dn(self):
        if self.FOCUS == 'lp':
            self.lp.next()
            self.st.nor(' │ '.join(self.selections[self.lp.get('cursor')]))
            self.update_rp()
            self.update_ip()
        else:
            self.rp.next()
            selection = self.rp.get()
            self.st.wrn(f'*? {selection}')
            self.update_ip()

    def move_left(self):
        self.FOCUS = 'lp'
        self.lp.focus(True)
        self.rp.focus(False)

    def move_right(self):
        self.FOCUS = 'rp'
        self.rp.focus(True)
        self.lp.focus(False)

    def set(self):
        '''
        change alternatives setting via UpAltAgent.set(...)
        '''
        if self.FOCUS == 'lp':
            self.status_hint()
            return
        name = self.selections[self.lp.get('cursor')][0]
        sele = self.rp.get().split()[-1]
        msg, code = self.ua.set(name, sele)
        if code == 2:
            self.st.err(f'Permission Denied. Are you root?')
        else:
            self.st.suc(f'{name} -> {sele}')
        self.update_rp()
        self.update_ip()


if __name__ == '__main__':

    ag = argparse.ArgumentParser()
    ag.add_argument(
        '-e',
        '--expression',
        type=str,
        default='',
        help='Filter the alternatives list')
    ag.add_argument(
        '-v',
        '--version',
        action='store_true',
        help='Print version information')
    ag = ag.parse_args()

    if ag.version:
        Version()
        exit()

    tb = tb()
    tb.clear()
    rv = Rover()

    if ag.expression:
        rv.reload_selections(ag.expression)
        rv.update_rp()
        rv.update_ip()

    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 (ch == 'q' and not state_input) or \
                (typ, key) == (termbox.EVENT_KEY, termbox.KEY_ESC):
                state_running = False
            # move down
            elif (ch == 'j' and not state_input) or \
                (typ, key) == (termbox.EVENT_KEY, termbox.KEY_ARROW_DOWN):
                rv.move_dn()
            # move up
            elif (ch == 'k' and not state_input) or \
                (typ, key) == (termbox.EVENT_KEY, termbox.KEY_ARROW_UP):
                rv.move_up()
            # move left
            elif (ch == 'h' and not state_input) or \
                (typ, key) == (termbox.EVENT_KEY, termbox.KEY_ARROW_LEFT):
                rv.move_left()
            # move right
            elif (ch == 'l' and not state_input) or \
                (typ, key) == (termbox.EVENT_KEY, termbox.KEY_ARROW_RIGHT):
                rv.move_right()
            # trigger regex input box
            elif ch in ('/', '?') and not state_input:
                state_input = True
                rv.status, rv.regex = '?> ', ''
                rv.st.vio(rv.status)
            # 2 cases for Enter key
            elif (typ, key) == (termbox.EVENT_KEY, termbox.KEY_ENTER):
                if state_input:
                    # end regex input
                    state_input = False
                    rv.reload_selections(rv.regex)
                    rv.update_rp()
                    rv.update_ip()
                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, rv.regex = map(lambda x: x + ch,
                                          (rv.status, rv.regex))
                rv.st.vio(rv.status)
            # delete character in regex input box
            elif state_input and termbox.KEY_BACKSPACE:
                rv.regex = rv.regex[:-1]
                rv.status = '?> ' + rv.regex
                rv.st.vio(rv.status)
            # 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()
    tb.close()
