# (c) Copyright 2018 by Coinkite Inc. This file is covered by license found in COPYING-CC.
#
# menu.py - Implement an interactive menu system.
#
import gc
from ux import PressRelease, the_ux
from uasyncio import sleep_ms
from charcodes import (KEY_UP, KEY_DOWN, KEY_HOME, KEY_SPACE, KEY_END,
                       KEY_PAGE_UP, KEY_PAGE_DOWN, KEY_ENTER, KEY_CANCEL)
from version import has_qwerty

# Number of full text lines per screen.
# - we will draw one past this because on Mk1-4 it shows a partial line under those 4
if not has_qwerty:
    PER_M = 4
else:
    from lcd_display import CHARS_H
    PER_M = CHARS_H - 1

def numpad_remap(key):
    # map from numpad+2 (12 keys) into symbolic names
    # - might only make sense within context of menus.
    if key == '5':
        return KEY_UP
    elif key == '8':
        return KEY_DOWN
    elif key == '7':
        return KEY_PAGE_UP
    elif key == '9':
        return KEY_END
    elif key == '0':
        return KEY_HOME
    elif key == 'y':
        return KEY_ENTER
    elif key == 'x':
        return KEY_CANCEL
    else:
        # keys 1-4 useful for selecting the top visible items from menu
        return key

def start_chooser(chooser):
    # get which one to show as selected, list of choices, and fcn to call after
    # - optional: a function to preview a value
    selected, choices, setter, *preview = chooser()

    if preview:
        preview, = preview

    async def picked(menu, picked, xx_self):
        menu.chosen = picked
        menu.show()
        await sleep_ms(100)     # visual feedback that we changed it
        setter(picked, choices[picked])

        the_ux.pop()

    # make a new menu, just for the choices
    m = MenuSystem([MenuItem(c, f=picked) for c in choices], chosen=selected)
    if preview:
        m.late_draw = lambda dis: preview(m.cursor)

    the_ux.push(m)

class MenuItem:
    def __init__(self, label, menu=None, f=None, chooser=None, arg=None,
                 predicate=None, shortcut=None):
        self.label = label
        self.arg = arg
        if menu:
            self.next_menu = menu
        if f:
            self.next_function = f
        if chooser:
            self.chooser = chooser
        if predicate is not None:
            self._predicate = predicate
        if shortcut:
            self.shortcut_key = shortcut

    def predicate(self):
        if not hasattr(self, "_predicate"):
            return True  # does not have predicate - allow
        if callable(self._predicate):
            return self._predicate()
        return self._predicate
    
    async def activate(self, menu, idx):

        if getattr(self, 'chooser', None):
            start_chooser(self.chooser)

        else:
            # nesting menus, and functions and so on.
            f = getattr(self, 'next_function', None)
            if f:
                rv = await f(menu, idx, self)
                if isinstance(rv, MenuSystem):
                    # XXX the function should do this itself, as the_ux.push(rv)
                    # replace current with new menu from function
                    the_ux.replace(rv)

            m = getattr(self, 'next_menu', None)

            if callable(m):
                m = await m(menu, idx, self)

            if isinstance(m, list):
                m = MenuSystem(m)

            if m:
                the_ux.push(m)

class ShortcutItem(MenuItem):
    # Add these to a menu to define action when a single special key is pressed.
    # - typically NFC and QR keys
    # - never displayed
    # - can have predicate
    def __init__(self, key, **kws):
        super().__init__('SHORTCUT', shortcut=key, **kws)

class NonDefaultMenuItem(MenuItem):
    # Show a checkmark if setting is defined and not the default
    def __init__(self, label, nvkey, prelogin=False, default_value=None, **kws):
        super().__init__(label, **kws)
        self.nvkey = nvkey
        self.prelogin = prelogin
        self.def_value = default_value       # treated the same as missing

    def is_chosen(self):
        # should we show a check in parent menu?
        if self.prelogin:
            from nvstore import SettingsObject
            s = SettingsObject.prelogin()
        else:
            from glob import settings
            s = settings

        return (s.get(self.nvkey, self.def_value) != self.def_value)


class ToggleMenuItem(MenuItem):
    # Handle toggles: must use undefined (missing) as default
    # - can remap values a little, but default is to store 0/1/2
    def __init__(self, label, nvkey, choices, predicate=None, story=None,
                 on_change=None, invert=False, value_map=None):
        super().__init__(label, predicate=predicate)
        self.story = story
        self.nvkey = nvkey
        self.choices = choices          # list of strings, at least 2
        self.on_change = on_change      # optional, since some are just settings
        if invert:
            self.invert = True
        if value_map:
            self.value_map = value_map

    def get(self, default=None):
        from glob import settings
        return settings.get(self.nvkey, default)

    def set(self, v):
        from glob import settings
        return settings.set(self.nvkey, v)

    def remove_key(self):
        from glob import settings
        return settings.remove_key(self.nvkey)

    def is_chosen(self):
        # should we show a check in parent menu?
        if self.nvkey == "chain":
            rv = True if self.get() in ["XRT", "XTN"] else False
        else:
            rv = bool(self.get(0))
        if getattr(self, 'invert', False):
            rv = not rv
        return rv
    
    async def activate(self, menu, idx):
        from ux import ux_show_story

        # skip story if default value has been changed
        if self.nvkey == "chain":
            default = (self.get() == "BTC")
        else:
            default = (self.get(None) is None)
        if self.story and default:
            ch = await ux_show_story(self.story)
            if ch == 'x': return

        value = self.get(0)
        if hasattr(self, 'value_map'):
            for n,v in enumerate(self.value_map):
                if value == v:
                    value = n
                    break
            else:
                value = 0           # robustness

        m = MenuSystem([MenuItem(c, f=self.picked) for c in self.choices], chosen=value)
        the_ux.push(m)

    async def picked(self, menu, picked, xx_self):
        menu.chosen = picked
        menu.show()
        await sleep_ms(100)     # visual feedback that we changed it

        if picked == 0:
            self.remove_key()
        else:
            if hasattr(self, 'value_map'):
                picked = self.value_map[picked]     # want IndexError if wrong here
            self.set(picked)

        if self.on_change:
            await self.on_change(picked)

        the_ux.pop()

class PreloginToggleMenuItem(ToggleMenuItem):
    # Handle toggle settings related to pre-login stuff

    def get(self, default=None):
        from nvstore import SettingsObject
        s = SettingsObject.prelogin()
        return s.get(self.nvkey, default)

    def set(self, v):
        from nvstore import SettingsObject
        s = SettingsObject.prelogin()
        return s.set(self.nvkey, v)

    def remove_key(self):
        from nvstore import SettingsObject
        s = SettingsObject.prelogin()
        return s.remove_key(self.nvkey)

class MenuSystem:

    def __init__(self, menu_items, chosen=None, should_cont=None,
                        space_indicators=False, multichoice=False):
        self.shortcuts = {}
        self.should_continue = should_cont or (lambda: True)
        self.replace_items(menu_items)
        self.space_indicators = space_indicators
        self.chosen = chosen
        if chosen is not None:
            self.goto_idx(chosen)
        self.multi_selected = [] if multichoice else None

    # subclasses: override us
    #
    def late_draw(self, dis):
        pass

    def update_contents(self):
        # something changed in system state; maybe re-construct menu contents
        pass

    def replace_items(self, menu_items, keep_position=False):
        # only safe to keep position if you know number of items isn't changing
        if not keep_position:
            self.cursor = 0
            self.ypos = 0

        self.items = [
            m
            for m in menu_items
            if not isinstance(m, ShortcutItem) and m.predicate()
        ]
        for m in menu_items:
            if isinstance(m, ShortcutItem):
                self.shortcuts[m.shortcut_key] = m

        self.count = len(self.items)

    def goto_label(self, label):
        # pick menu item based on label text
        for i, m in enumerate(self.items):
            if m.label.endswith(label):
                self.goto_idx(i)
                return True
        return False

    def show(self):
        #
        # Redraw the menu.
        #
        from glob import dis

        dis.clear()

        cursor_y = None
        for n in range(self.ypos+PER_M+1):
            real_idx = n+self.ypos
            if real_idx >= self.count: break

            msg = self.items[real_idx].label
            is_sel = (self.cursor == real_idx)
            if is_sel:
                cursor_y = n

            # show check?
            checked = (self.chosen is not None) and (real_idx == self.chosen)

            fcn = getattr(self.items[real_idx], 'is_chosen', None)
            if fcn and fcn():
                checked = True

            if self.multi_selected is not None and (real_idx in self.multi_selected):
                # ignore length constraint above, we need to visually show that
                # smthg is selected - in any case
                # currently only used with XFPs so checkmark always good
                checked = True

            dis.menu_draw(n, msg, is_sel, checked, self.space_indicators)

        # subclass hook
        self.late_draw(dis)

        if self.count > PER_M:
            dis.scroll_bar(self.ypos, self.count, PER_M)

        dis.menu_show(cursor_y)

    def should_wrap_menu(self):
        from glob import settings
        # "wa" is boolean value from config:
        # True --> wrap around all menus
        # False --> (default) wrap around is active only for menus with length > WRAP_IF_OVER
        wrap = settings.get("wa", 0)
        if wrap: return True

        # Do wrap-around (by request from NVK) if longer than the screen itself (on Q),
        # Mk4: same limit
        return self.count > 10

    def down(self):
        if self.cursor < self.count-1:
            self.cursor += 1

            if self.cursor - self.ypos >= (PER_M-1):
                self.ypos += 1
        else:
            if self.should_wrap_menu():
                self.goto_idx(0)

    def up(self):
        if self.cursor > 0:
            self.cursor -= 1
            if self.cursor < self.ypos:
                self.ypos -= 1
        else:
            if self.should_wrap_menu():
                self.goto_idx(self.count - 1)

    def top(self):
        self.cursor = 0
        self.ypos = 0

    def goto_idx(self, n):
        # skip to any item, force cusor near middle of screen
        n = self.count-1 if n >= self.count else n
        n = 0 if n < 0 else n
        self.cursor = n
        if n < PER_M-1:
            self.ypos = 0
        else:
            self.ypos = n - 2

    def page(self, n):
        # relative page dn/up - may wrap around
        if n == 1:
            for i in range(PER_M):
                self.down()
        else:
            for i in range(PER_M):
                self.up()

    # events
    async def on_cancel(self):
        # override me
        if the_ux.pop():
            # top of stack (main top-level menu)
            self.top()

    async def activate(self, picked):
        # Activate a specific choice in our menu.
        #
        if picked is None:
            # "go back" or cancel or something
            await self.on_cancel()
        else:
            await picked.activate(self, self.cursor)


    async def interact(self):
        # Only public entry point: do stuff.
        #
        while self.should_continue() and the_ux.top_of_stack() == self:
            ch = await self.wait_choice()
            gc.collect()
            if self.multi_selected is not None:
                # multichoice
                await self.on_cancel()
                return ch

            await self.activate(ch)
            
    async def wait_choice(self):
        # Wait until a menu choice is picked; let them move around
        # the menu, keep redrawing it and so on.
        # returns the item picked, or None for cancel=Back

        key = None

        # 5,8 have key-repeat, not others
        pr = PressRelease('790xy')      # on Q, arg is ignored

        while 1:
            self.show()

            key = await pr.wait()

            if not key:
                continue

            if not has_qwerty:
                key = numpad_remap(key)

            if self.multi_selected is not None and (key == "1"):  #1 is select/deselect key for both HW
                # multichoice
                if self.cursor in self.multi_selected:
                    # already chosen - and user pressed again
                    # unselect
                    self.multi_selected.remove(self.cursor)
                else:
                    # select
                    self.multi_selected.append(self.cursor)

            elif key in KEY_ENTER+KEY_SPACE:
                if self.multi_selected is not None:
                    # selected - multichoice done
                    return self.multi_selected

                return self.items[self.cursor]

            elif key == KEY_CANCEL:
                # abort/nothing selected/back out?
                return None
            elif key == KEY_UP:
                self.up()
            elif key == KEY_DOWN:
                self.down()
            elif key == KEY_PAGE_UP:
                self.page(-1)
            elif key == KEY_PAGE_DOWN:
                self.page(1)
            elif key == KEY_END:
                self.goto_idx(self.count-1)
            elif key == KEY_HOME:
                # zip to top, no selection
                self.cursor = 0
                self.ypos = 0
            elif '1' <= key <= '9':
                # jump down, based on screen postion
                self.goto_idx(ord(key)-ord('1'))
            elif key in self.shortcuts:
                # run the function, if predicate allows
                m = self.shortcuts[key]
                if m.predicate():
                    return m
            else:
                # maybe a shortcut?
                for n, item in enumerate(self.items):
                    if getattr(item, 'shortcut_key', None) == key:
                        # matched. do it
                        self.goto_idx(n)
                        return self.items[self.cursor]

                # search downwards for a menu item that starts with indicated letter
                # if found, select it but don't drill down
                lst = list(range(self.cursor+1, self.count)) + list(range(0, self.cursor))
                for n in lst:
                    if self.items[n].label[0].upper() == key.upper():
                        self.goto_idx(n)
                        break

# EOF
