# (c) Copyright 2018 by Coinkite Inc. This file is covered by license found in COPYING-CC.
#
# ux_q1.py - UX/UI interactions that are Q1 specific and use big screen, keyboard.
#
import utime, gc, ngu, sys, bip39
import uasyncio as asyncio
from uasyncio import sleep_ms
from charcodes import *
from lcd_display import CHARS_W, CHARS_H, CursorSpec, CURSOR_SOLID, CURSOR_OUTLINE
from exceptions import AbortInteraction, QRDecodeExplained
from decoders import decode_qr_result
from ubinascii import hexlify as b2a_hex
from ubinascii import b2a_base64

from utils import problem_file_line, show_single_address
from public_constants import MSG_SIGNING_MAX_LENGTH
from glob import numpad         # may be None depending on import order, careful

class PressRelease:
    def __init__(self, need_release=KEY_ENTER+KEY_CANCEL):
        # Manage key-repeat: track last key, measure time it's held down, etc.
        self.need_release = need_release
        self.last_key = None
        self.num_repeats = 0

        global numpad
        if not numpad:
            from glob import numpad

    async def wait(self):

        armed = None
        while 1:
            # two values here:
            #  - (ms) time to wait before first key-repeat
            #  - (ms) time between 2nd and Nth repeated events
            #  - these values approved by @nvk
            rep_delay = 20 if self.num_repeats else 200

            # busy-wait on key arrivial
            # - would like to use asyncio.wait_for_ms but causes random CancelledError's elsewhere
            for i in range(rep_delay//2):
                if not numpad.empty():
                    break
                await sleep_ms(2)

            if numpad.empty():
                # nothing changed, do key repeat
                if self.last_key and numpad.key_pressed == self.last_key:
                    self.num_repeats += 1
                    return self.last_key
                continue

            ch = numpad.get_nowait()

            if ch == numpad.ABORT_KEY:
                raise AbortInteraction()

            self.num_repeats = 0

            if len(ch) > 1:
                # multipress: cancel press/release cycle and be a keyup
                # for other keys.
                armed = None
                continue

            if ch == '':
                # all keys are now UP
                self.last_key = None
                if armed:
                    return armed
            elif ch in self.need_release:
                # no key-repeat on these ones
                armed = ch
            else:
                self.last_key = ch
                return ch

async def ux_enter_number(prompt, max_value, can_cancel=False, value=''):
    # return the decimal number which the user has entered
    # - default/blank value assumed to be zero
    # - clamps large values to the max
    from glob import dis
    from math import log

    # allow key repeat on X only?
    press = PressRelease()

    value = str(value)
    max_w = int(log(max_value, 10) + 1)

    dis.clear()
    dis.text(None, -1, "CANCEL or ENTER when done." if can_cancel else
                       "Enter number, ENTER when done.")

    while 1:
        # check width, go to two lines if needed? depends on prompt text
        if len(prompt) + 1 + max_w >= CHARS_W:
            dis.text(0, 3, prompt[-CHARS_W:])      # may still be truncated, oh well.
            if len(prompt) > CHARS_W:
                dis.text(0, 3, '⋯')
            bx = dis.text(2, 4, value)
        else:
            bx = dis.text(2, 4, prompt + ' ' + value)
        dis.show(cursor=CursorSpec(bx, 4, CURSOR_SOLID))

        ch = await press.wait()
        if ch == KEY_ENTER:

            if not value:
                return 0

            return min(max_value, int(value))

        elif ch == KEY_DELETE:
            if value:
                value = value[0:-1]
                dis.text(0, 4, ' '*CHARS_W)
        elif ch == KEY_CLEAR:
            value = ''
            dis.text(0, 4, ' '*CHARS_W)
        elif ch == KEY_CANCEL:
            if can_cancel:
                # quit if they press X on empty screen
                return None
        elif '0' <= ch <= '9':
            if len(value) == max_w:
                value = value[0:-1] + ch
            else:
                value += ch

            # cleanup leading zeros and such
            value = str(min(int(value), max_value))

async def ux_input_text(value, confirm_exit=False, hex_only=False, max_len=100,
            prompt='Enter value', min_len=0, b39_complete=False, scan_ok=False,
            placeholder=None, funct_keys=None, force_xy=None):
    # Get a text string.
    # - Should allow full unicode, NKDN
    # - but our font is mostly just ascii
    # - no control chars allowed either
    # - press QR -> do scan and use that text
    # - funct_keys => CTA msg, and map of Fn key to async-function which takes and returns new text
    # - TODO: regex validation for derviation paths?
    # - TODO: arrowing around, insertion cursor, delete-left vs -right, etc
    # - if unlimited length, then we allow newlines and CANCEL is only way out.
    # - multiline entries don't mean newlines are allowed, because we often have to wrap
    #   to make longer single-line value onto screen
     # - confirm_exit default False here, because so easy to re-enter w/ qwerty, True on mk4
    from glob import dis

    MAX_LINES = 7        # without scroll
    can_scroll = False

    value = value or ''

    # map from what they entered, to allowed char. None if not allowed char
    # - can case fold if desired
    ch_remap = lambda ch: ch if ' ' <= ch < chr(127) else None
    if hex_only:
        ch_remap = lambda ch: ch.lower() if ch in '0123456789abcdefABCDEF' else None

    line_len = CHARS_W-2
    y = 2
    if max_len is None:
        # try hard to support "unlimited" entry, with scrolling and all that.
        num_lines = CHARS_H-2
        line_len = CHARS_W
        can_scroll = True
    elif max_len <= line_len:
        # single-line or perhaps shorter value
        line_len = max_len
        num_lines = 1
        y = 4
    elif max_len == 100:
        # passphrase case, handle nicely
        line_len = 25
        num_lines = 4
    else:
        # non-scrolling but still multi-line mode
        num_lines, runt = divmod(max_len, line_len)
        if runt:
            num_lines += 1
        assert num_lines <= MAX_LINES, num_lines       # too big to fit w/o scroll

    if force_xy:
        x, y = force_xy
    else:
        dis.clear()

    if not can_scroll and not force_xy:
        # Normal, no-scrolling case
        if funct_keys:
            msg, funct_keys = funct_keys
            dis.text(None, -2, msg, dark=True)

        if b39_complete or scan_ok:
            msg = []
            if b39_complete:
                msg.append(KEY_TAB + " to auto-complete.")
            if scan_ok:
                msg.append(KEY_QR + " to scan.")
            dis.text(None, -1, ' '.join(msg), dark=True)

        elif num_lines <= 2:
            # show this dumb CTA only if screen mostly blank
            dis.text(None, -1, "CANCEL or ENTER when done.", dark=True)

        dis.text(None, y-2, prompt)
        x = dis.draw_box(None, y-1, line_len, num_lines, dark=True)
    elif can_scroll:
        # Scrolling, max-screen space mode (unlimited length for notes)
        dis.text(None, 0, prompt)

        # maybe some guide "icons" in top-left
        msg = ''
        if scan_ok:
            msg += KEY_QR
        if b39_complete:
            msg += KEY_TAB
        if msg:
            dis.text(-1, 0, msg, dark=True)

        dis.text(0, 0, KEY_TAB, dark=True)

        dis.text(None, 1, '┅'*CHARS_W, dark=True)
        x = 0
        y = 2

    # NOTE:
    #  - x,y here are top left of entry area
    #  - does not allow cursor movement, always appending to end (for now)

    # no key-repeat on certain keys
    err_msg = last_err = None
    press = PressRelease()
    exit_armed = False
    while 1:
        dis.clear_box(x, y, line_len, num_lines)

        if not can_scroll:
            # show error msg, until they type anything to clear it
            if err_msg:
                dis.text(None, y+num_lines+1, err_msg, dark=True)
                err_msg = None
                last_err = True
            elif last_err:
                dis.text(None, y+num_lines+1, '')
                last_err = False

        if not can_scroll or not value:
            if not value:
                bx = 0
                n = 0
                if placeholder:
                    dis.text(x, y, placeholder, dark=True)
            elif not can_scroll:
                for n, ln_pos in enumerate(range(0, len(value), line_len)):
                    ln = value[ln_pos:ln_pos+line_len]
                    dis.text(x, y+n, ln)
                    bx = len(ln)
        else:
            # scrollable case
            lines = []
            for ln in value.split('\n'):
                if len(ln) <= line_len:
                    lines.append(ln)
                else:
                    for pp in range(0, len(ln), line_len):
                        lines.append(ln[pp:pp+line_len])

            for n, ln in enumerate(lines[-MAX_LINES:]):
                dis.text(x, y+n, ln)
                bx = len(ln)

            top_y = max(0, len(lines) - MAX_LINES)
            if top_y:
                dis.scroll_bar(top_y, len(lines), MAX_LINES)

        # decide cursor location
        # - if on final possible location, adjust over top of final char
        cur = CursorSpec(min(x+bx, x+line_len-1), y+n, CURSOR_OUTLINE)
        dis.show(cursor=cur)

        ch = await press.wait()

        if ch != KEY_CANCEL:
            exit_armed = False

        if ch == KEY_ENTER:
            if can_scroll:
                value += '\n'
            else:
                if len(value) >= min_len:
                    break
                else:
                    err_msg = 'Need %d characters at least.' % min_len
        elif ch == KEY_DELETE or ch == KEY_LEFT:
            if len(value) > 0:
                # delete last char
                value = value[:-1]
        elif ch == KEY_CLEAR:
            value = ''
        elif ch == KEY_CANCEL:
            if confirm_exit:
                if exit_armed:
                    value = None
                    break
                err_msg = 'Confirm exit w/o change?'
                exit_armed = True
                continue
            else:
                if can_scroll:
                    # CANCEL/TAB are only way out in scrolling mode
                    # - cleanup blank lines at end, etc
                    value = value.strip()
                    break
                value = None
                break
        elif can_scroll and ch == KEY_TAB:
            # allow tab as escape from multiline mode
            value = value.strip()
            break

        elif ch == KEY_QR and scan_ok:
            # Insert or replace? I think replace in most cases, but not if long msg.
            # - always show result
            ss = dis.save_state()
            zz = QRScannerInteraction()
            got = await zz.scan_text('Scan any QR or Barcode for text.')
            if got:     # not canceled, etc
                if not value or not can_scroll:
                    # whole QR should be it, no more editing
                    # - but not if too long
                    if not max_len or len(got) <= max_len:
                        value = got
                        if len(value) >= 60:
                            break
                    else:
                        err_msg = "QR data too long! (max %d)" % max_len
                else:
                    # add onto end, if inf length supported, and they aren't on first char
                    # - adds a line break for them too
                    if value[-1] != '\n':
                        value += '\n'
                    value += got

            dis.restore_state(ss)

        elif b39_complete and ch == KEY_TAB:
            # match case and auto-complete BIP-39 word if we can
            # - search backwards for alpha chars, up to 5
            # - stop on first non-letter
            # - break if case changes, so "ZooAct" gives "Act"
            pref = []
            for b in reversed(value[-4:]):
                if not b: break
                if 'a' <= b.lower() <= 'z':
                    pref.insert(0, b)
                    if len(pref)>=1 and b.isupper() != pref[0].isupper():
                        break
                else:
                    break
            if not pref:
                #err_msg = 'Need some letters first.'
                continue

            pref = ''.join(pref)
            exact, nextchars, is_word = bip39.next_char(pref.lower())

            if not is_word and len(nextchars) == 1:
                # only one possible next char, so complete for them
                # example "yo" => "you"
                is_word = pref + nextchars[0]

            #N OTE: if exact and not is_word:
            # example "act" -> could be "act" or "actor" etc.
            # but they pressed auto-complete and not space, so they want next char info
                
            if is_word:
                # got a match; append it
                if pref.isupper():
                    # all upper case, so append w/ same
                    # - Titlecase will just happen w/o any code here
                    is_word = is_word.upper()

                value += is_word[len(pref):]

            elif not nextchars:
                err_msg = 'Not a BIP-39 word: ' + pref
            elif len(nextchars) < 18:
                # 'sta' and other s-prefixes can have many choices!
                # 'act' could be by itself, or 'actual', etc.
                if exact:
                    nextchars += '␣'
                err_msg = 'Press next key: ' + nextchars
            else:
                err_msg = 'Need more letters.'

        elif funct_keys and (ch in funct_keys):
            # replace w/ function output ... might do a transform, or not
            value = await funct_keys[ch](value)
        else:
            ch = ch_remap(ch)
            if ch is not None:
                if not max_len or len(value) < max_len:
                    value += ch
                else:
                    value = value[0:max_len-1] + ch

    return value

def ux_show_phish_words(dis, words):
    # Show the anti-phishing words
    x = 34//2
    y = 7
    if not words:
        # just clear line
        dis.clear_box(0, y, CHARS_W, 1)
    else:
        dis.text(x - len(words[0]) - 1, y,   words[0])
        dis.text(x + 1, y, words[1])

def ux_show_pin(dis, pin, subtitle, prefix, is_confirmation, force_draw,
                    footer=None, randomize=None):

    # Draw PIN during entry / reentry / changing or setting

    # verticals
    rnd_y = 0           # jammed in at top; doesn't look great but rarely used?
    foot_y = -1         # footer at foot
    y = 4               # main focus area, center line
    if randomize: 
        y += 1

    # for MAX_PIN_PART_LEN==6, and one char margin both sides
    x = 6
    w = 8

    # position of prefix/suffix digits
    ppx = x + 2
    ssx = x + w + 6

    if force_draw:
        dis.clear()

        if randomize:
            # screen redraw, when we are "randomized"
            # - only used at login, none of the other cases
            # - test w/ "simulator.py --q1 -g --eff --set rngk=1"

            # show mapping of numbers vs. PIN digits
            dis.text(1, rnd_y+0, '  ' + '  '.join(randomize[1:]) +'  '+ randomize[0] + '  ', invert=1)
            dis.text(1, rnd_y+1, '↳ 1  2  3  4  5  6  7  8  9  0')

        dis.text(x+w+2, y, '⋯', dark=True)

        if footer:
            # ie. '1 failures, 12 tries left'
            dis.text(None, foot_y, footer, dark=True)
        elif is_confirmation:
            dis.text(None, foot_y, "Confirm PIN value")
    else:
        dis.clear_box(ppx, y, 6, 1)
        dis.clear_box(ssx, y, 6, 1)
        if not prefix:
            ux_show_phish_words(dis, None)

    # prefix/not prefix can change anytime, so redraw this stuff
    dis.draw_box(x, y-1, w, 1, dark=bool(prefix))
    dis.draw_box(x+w+4, y-1, w, 1, dark=not bool(prefix))

    prompt = "Enter first part of PIN" if not prefix else "Enter second part of PIN" 

    if not subtitle:
        dis.text(None, y-2, prompt)
    else:
        # "New Main PIN" and similar
        dis.text(None, y-3, subtitle, dark=False)
        dis.text(None, y-2, prompt, dark=True)

    # show dots            
    active = '•' * len(prefix or pin)

    if prefix:
        # show both first part and second
        suffix = '•' * len(pin)
        dis.text(ppx, y, active, dark=True)
        cur_x = dis.text(ssx, y, suffix)
    else:
        # just showing first part
        cur_x = dis.text(ppx, y, active)
        dis.clear_box(ssx, y, 6, 1)

    if len(pin) == 6:
        # cursor on final 6th digit
        dis.show(cursor=CursorSpec(cur_x-1, y, CURSOR_OUTLINE))
    else:
        dis.show(cursor=CursorSpec(cur_x, y, CURSOR_SOLID))

async def ux_login_countdown(sec):
    # Show a countdown, which may need to
    # run for multiple **days**
    # - test with: ./simulator.py --q1 -g --eff --set lgto=60
    # - test with: ./simulator.py --q1 -g --eff --set lgto=3600
    # - test with: ./simulator.py --q1 -g --eff --set lgto=2419200
    from glob import dis
    from utime import ticks_ms, ticks_diff
    from utils import pretty_short_delay, pretty_delay

    y = 1
    dis.clear()
    dis.text(None, y, "Login countdown in effect.", invert=1)
    dis.text(None, y+2, "Must wait:")

    st = ticks_ms()
    while sec > 0:
        txt = pretty_delay(sec) if sec > 12*3600 else pretty_short_delay(sec)
        dis.text(None, y+4, txt)
        dis.busy_bar(1)

        # this should be more accurate, errors were accumulating
        now = ticks_ms()
        dt = 1000 - ticks_diff(now, st)
        await sleep_ms(dt)
        st = ticks_ms()

        sec -= 1

    dis.busy_bar(0)

def ux_render_words(words, leading_blanks=0):
    # re-use word-list rendering code to show as a string in a story.
    # - because I want them all on-screen at once, and not simple to do that
    rv = [''] * leading_blanks

    num_words = len(words)
    if num_words == 12:
        for y in range(6):
            # no need to use NOWRAP here, will always fit (2 word columns)
            rv.append('%2d: %-8s   %2d: %s' % (y+1, words[y], y+7, words[y+6]))
    else:
        lines = 6 if num_words == 18 else 8
        for y in range(lines):
            rv.append(OUT_CTRL_NOWRAP+'%d:%-8s %2d:%-8s %2d:%s' % (
                y+1, words[y], y+lines+1, words[y+lines],
                y+(lines*2)+1, words[y+(lines*2)]))

    return '\n'.join(rv)
    

def ux_draw_words(y, num_words, words):
    # Draw seed words on single screen (hard) and return x/y position of start of each
    from glob import dis

    if num_words == 2:
        # simple version for first & last words, used only during login to spending policy
        X = 14
        Y = y+1
        dis.text(X-7, Y, 'FIRST: %s' % words[0])
        dis.text(X-4, Y+1, '⋯')
        dis.text(X-6, Y+2, 'LAST: %s' % words[-1])

        return [ (X, Y), (X, Y+2) ]

    if num_words == 12:
        cols = 2
        xpos = [2, 18]
    else:
        assert num_words in (18, 24)
        cols = 3
        xpos = [0, 11, 23]

    n_per_c = num_words // cols     #   6/4/8

    rv = []
    for n, word in enumerate(words, 1):
        if num_words == 12:
            # luxious space after colon
            msg = ('%2d: ' % n) + word
            x_off = 3
        else:
            if n <= n_per_c:
                # no space in front of 1: thru N: in leftmost column of 3
                msg = ('%d:' % n) + word
                x_off = 2
            else:
                msg = ('%2d:' % n) + word
                x_off = 3

        X, Y = xpos[(n-1) // n_per_c], y + ((n-1) % n_per_c)
        dis.text(X, Y, msg)
        rv.append( (X+x_off, Y) )

    return rv

async def seed_word_entry(prompt, num_words, has_checksum=True, done_cb=None, line2=None):
    # Accept a seed phrase, only
    # - replaces WordNestMenu on Q1
    # - max word length is 8, min is 3
    # - useful: simulator.py --q1 --eff --seq 'aa ee 4i '
    from glob import dis
    from ux import ux_confirm

    assert num_words and prompt

    not24 = (num_words != 24)

    def redraw_words(wrds=None):
        if not wrds:
            wrds = ['' for _ in range(num_words)]

        dis.clear()
        dis.text(None, 0, prompt, invert=1)

        Y = 2 if not24 else 1
        if line2 and not24:
            # add second line, if provided, but only if words length < 24
            # currently only used to show backup filename during backup pwd entry
            dis.text(None, 1, line2, invert=1)
            Y += 1

        p = ux_draw_words(Y, num_words, wrds)
        return wrds, p

    words, pos = redraw_words()

    word_num = 0
    value = ''
    err_msg = last_err = None
    press = PressRelease()
    last_words = []
    while 1:
        final = (word_num == num_words)
        if final:
            # useful to show final word on screen, even tho confirm not needed
            err_msg = 'Press ENTER if all done.' if not has_checksum else \
                      'Valid words! Press ENTER.'
            cur = None
        else:
            x, y = pos[word_num]
            ln = len(value)
            if ln == 8:
                # outline-style cursor if on top of final possible location
                cur = CursorSpec(x+ln-1, y, CURSOR_OUTLINE)
            else:
                cur = CursorSpec(x+ln, y, CURSOR_SOLID)

            dis.text(x, y, '%-8s' % value)

        # show error msg, until they type anything to clear it
        if err_msg:
            dis.text(None, -1, err_msg, dark=True)
            err_msg = None
            last_err = True
        elif last_err:
            dis.text(None, -1, '')
            last_err = False

        dis.show(cursor=cur)
        ch = await press.wait()

        commit = False
        if ch == KEY_QR:
            try:
                got = await QRScannerInteraction.scan('Scan seed from a QR code')
                what, vals = decode_qr_result(got, expect_secret=True)
            except QRDecodeExplained as e:
                err_msg = str(e)
                redraw_words()
                continue

            if what != "words":
                err_msg = "Must be seed words, not %s" % what
            elif num_words != len(vals[0]):
                err_msg = "Must be seed of length %d, not %s" % (num_words, len(vals[0]))
            else:
                words = vals[0]
                # offer just the actual imported csum if user deletes csum word
                last_words = [words[-1]]
                word_num = num_words

            # needs redraw, empty on error with error below
            # if success, qr imported words shown to user
            redraw_words(words)

        elif ch == KEY_ENTER:
            if final:
                break
            commit = True
        elif (ch == KEY_DELETE) or (ch == KEY_LEFT):
            # delete last char
            if len(value) > 0:
                value = value[:-1]
            elif word_num:
                # go to prev word
                word_num -= 1
                words[word_num] = value = ''
                
        elif ch == KEY_CLEAR:
            value = ''
        elif ch == KEY_CANCEL:
            if word_num >= 2:
                tmp = dis.save_state()
                if not await ux_confirm("Everything you've entered will be lost."):
                    dis.restore_state(tmp)
                    continue
            return None
        elif final:
            # below options not allowed if all words already provided
            continue
        elif ch in {' ', KEY_TAB, KEY_DOWN, KEY_RIGHT}:
            # re-consider if word done, like "act" and other 3-letter cases
            commit = True
        elif ch.isalpha():
            value += ch.lower()
        else:
            continue

        if has_checksum and (word_num == num_words-1) and ((len(value) >= 1) or commit):
            assert last_words
            if value not in last_words:
                maybe = [i for i in last_words if i.startswith(value)]
                if len(maybe) == 1:
                    value = maybe[0]
                elif not maybe:
                    if len(last_words) == 8:        # 24 words case
                        ll = ''.join(sorted(set([w[0] for w in last_words])))
                        err_msg = 'Final word starts with: ' + ll
                    else:
                                   ##################################
                        err_msg = "Final word cannot start with: " + value
                    value = value[:-1]
                    continue
                else:
                    nextchars = ''.join(sorted(set(i[len(value)] for i in maybe)))
                    err_msg = 'Next key: ' + nextchars
                    continue

            if value in last_words:
                dis.text(x, y, '%-8s' % value)
                words[word_num] = value
                word_num += 1
                value = ''
                continue
        
        if len(value) >= 2:
            exact, nextchars, is_word = bip39.next_char(value)
            #print('%s => exact=%s nextchars=%s is_word=%s' % (value, exact, nextchars, is_word))

            if exact and not is_word and commit:
                # they pressed space after a valid 3 letter prefix (act vs actor)
                is_word = value

            if is_word:
                # word is from list, so we are done... move to next word
                words[word_num] = is_word
                dis.text(x, y, '%-8s' % is_word)
                word_num += 1
                value = ''

                if has_checksum and word_num == num_words-1:
                    # calc all possible final words
                    # 12 -> 128, 18->32, 24->8
                    last_words = list(bip39.a2b_words_guess(words[:-1]))

            elif not nextchars:
                err_msg = 'Not a BIP-39 word: ' + value
                value = value[0:3]
            else:
                err_msg = 'Next key: ' + nextchars

    if done_cb:
        await done_cb(words)

    return words

def ux_dice_rolling():
    from glob import dis

    # draw fixed parts of screen
    dis.clear()
    dis.text(0, 1, "Press 1-6 for each dice roll")
    dis.text(0, 2, "to mix in.")

    def update(count, hx=None):
        dis.text(None, 4, '%d rolls so far' % count, invert=1)

        if hx is not None:
            dis.text(0, -2, hx[0:32]+'-', dark=True)
            dis.text(2, -1, ''+hx[32:], dark=True)

        dis.show()

    # return funct to draw updating part
    return update

class QRScannerInteraction:
    def __init__(self):
        pass

    @staticmethod
    async def scan(prompt, line2=None, enter_quits=False):
        # draw animation, while waiting for them to scan something
        # - CANCEL to abort
        # - returns a string, BBQr object or None.
        from glob import dis, SCAN
        from ux import ux_wait_keydown
        frames = [ 1, 2, 3, 4, 5, 4, 3, 2 ]

        if not SCAN:
            raise QRDecodeExplained("Hardware fault.")

        dis.clear()
        dis.text(None, -2, prompt)
        if line2:
            dis.text(None, -1, line2, dark=True)
        dis.show()

        task = asyncio.create_task(SCAN.scan_once())

        escape = KEY_CANCEL + (KEY_ENTER if enter_quits else '')

        ph = 0
        while 1:
            if task.done():
                data = await task
                #print("Scanned: %r" % data)
                break

            dis.image(None, 40, 'scan_%d' % frames[ph])
            ph  = (ph + 1) % len(frames)

            # wait for key or 250ms animation delay
            ch = await ux_wait_keydown(escape, 250)

            if ch and (ch in escape):
                data = None
                break

        task.cancel()

        # clear screen right away so user knows we got it
        dis.clear()
        dis.show()

        return data

    async def scan_general(self, prompt, convertor, line2=None, enter_quits=False):
        # Scan stuff, and parse it .. raise QRDecodeExplained if you don't like it
        # continues until something is accepted
        problem = line2

        while 1:
            try:
                got = await self.scan(prompt, line2=problem, enter_quits=enter_quits)
                if got is None:
                    return None

                # Decode BBQr but not anything more complex
                return convertor(got)
            except QRDecodeExplained as exc:
                problem = str(exc)
                continue
            except Exception as exc:
                # import sys; sys.print_exception(exc)
                problem = "Unable to decode QR"
                continue


    async def scan_text(self, prompt):
        # Scan and return a text string. For things like BIP-39 passphrase
        # and perhaps they are re-using a QR from something else. Don't act on contents.
        def convertor(got):
            return decode_qr_result(got, expect_text=True)
        return await self.scan_general(prompt, convertor)

    async def scan_json(self, prompt):
        # Scan for a BBQr and a BBQr object. Converts sometimes?
        def convertor(got):
            file_type, _, data = decode_qr_result(got, expect_bbqr=True)
            if file_type == 'U':
                data = data.strip()
                if data[0] == '{' and data[-1] == '}':
                    file_type = 'J'
            if file_type != 'J':
                raise QRDecodeExplained('Expected JSON data')
            try:
                import json
                return json.loads(data)
            except:
                raise QRDecodeExplained('Unable to decode JSON data')
            
        return await self.scan_general(prompt, convertor)

    async def scan_for_addresses(self, prompt, line2=None):
        # accept only payment addresses; strips BIP-21 junk that might be there
        # - always a list result, might be size one
        from utils import decode_bip21_text

        def addr_taster(got):
            # could be muliple-line text file via BBQR or single line
            got = decode_qr_result(got, expect_text=True)

            try:
                rv = []
                for ln in got.split():
                    what, args = decode_bip21_text(ln)
                    if what == 'addr':
                        rv.append(args[1])
                if rv:
                    return rv
            except QRDecodeExplained:
                raise
            except:
                pass
            raise QRDecodeExplained("Not a payment address?")

        return await self.scan_general(prompt, addr_taster, line2=line2, enter_quits=True)


    async def scan_anything(self, expect_secret=False, tmp=False, miniscript_wallet=None):
        # start a QR scan, and act on what we find, whatever it may be.
        from ux import ux_show_story
        from pincodes import pa

        problem = None
        while 1:
            prompt = 'Scan any QR code, or CANCEL' if not expect_secret else \
                        'Scan XPRV or Seed Words, or CANCEL'

            try:
                got = await self.scan(prompt, line2=problem)
                if got is None:
                    return

                # Figure out what we got.
                what, vals = decode_qr_result(got, expect_secret=expect_secret)
                break
            except QRDecodeExplained as exc:
                problem = str(exc)
                continue
            except Exception:
                # import sys; sys.print_exception(exc)
                problem = "Unable to decode QR"
                continue

        if pa.hobbled_mode:
            # block most imports in hobbled mode.
            # - specific checks in place for teleport (PSBT is okay)
            from ccc import sssp_spending_policy
            whitelist = {'psbt', 'addr', 'vmsg', 'text', 'xpub', 'teleport' }

            sv_ok = sssp_spending_policy('okeys')
            if sv_ok:
                # seed vault, and tmp seeds are okay with user, even in hobble mode
                whitelist.update({'xprv', 'words'})

            if what not in whitelist:
                await ux_show_story("Blocked when Spending Policy is in force.", title='Sorry')
                return

        if what == 'xprv':
            from actions import import_extended_key_as_secret
            text_xprv, = vals
            await import_extended_key_as_secret(text_xprv, tmp)
            return

        if what == 'words':
            from seed import commit_new_words, set_ephemeral_seed_words       # dirty API
            words, = vals
            if tmp:
                await set_ephemeral_seed_words(words, 'From QR')
            else:
                await commit_new_words(words)

            return

        if what == 'psbt':
            decoder, psbt_len, got = vals
            await qr_psbt_sign(decoder, psbt_len, got, miniscript_wallet)

        elif what == 'txn':
            bin_txn, = vals
            await ux_visualize_txn(bin_txn)

        elif what == 'addr':
            proto, addr, args = vals
            await ux_visualize_bip21(proto, addr, args)

        elif what == "minisc":
            from auth import maybe_enroll_xpub
            ms_config, = vals
            try:
                maybe_enroll_xpub(config=ms_config)
            except Exception as e:
                await ux_show_story(
                    'Failed to import.\n\n%s\n%s' % (e, problem_file_line(e)))
            return

        elif what == "wif":
            data, = vals
            wif_str, key_pair, compressed, testnet = data
            from wif import ux_visualize_wif
            await ux_visualize_wif(wif_str, key_pair, compressed, testnet)

        elif what == "vmsg":
            data, = vals
            from msgsign import verify_armored_signed_msg
            await verify_armored_signed_msg(data)

        elif what == "smsg":
            data, = vals
            from auth import approve_msg_sign
            from msgsign import msg_signing_done
            await approve_msg_sign(None, None, None,
                                   msg_sign_request=data, kill_menu=True,
                                   approved_cb=msg_signing_done)

        elif what == 'text' or what == 'xpub':
            # we couldn't really decode it.
            txt, = vals
            await ux_visualize_textqr(txt)

        elif what == 'teleport':
            from teleport import kt_incoming
            await kt_incoming(*vals)

        else:
            await ux_show_story(what, title='Unhandled')


async def qr_psbt_sign(decoder, psbt_len, raw, miniscript_wallet=None):
    # Got a PSBT coming in from QR scanner. Sign it.
    # - similar to auth.sign_psbt_file()
    from auth import UserAuthorizedAction, ApproveTransaction
    from ux import the_ux
    from sffile import SFFile
    from auth import TXN_INPUT_OFFSET, psbt_encoding_taster

    if raw != 'PSRAM':      # might already be in place
        # copy to PSRAM, and convert encoding at same time
        if isinstance(raw, str):
            raw = raw.encode()

        _, output_encoder, _ = psbt_encoding_taster(raw[:10], psbt_len)
        total = 0
        with SFFile(TXN_INPUT_OFFSET, max_size=psbt_len) as out:
            if not decoder:
                total += out.write(raw)
            else:
                for here in decoder.more(raw):
                    out.write(here)
                    total += len(here)

        # might have been whitespace inflating initial estimate of PSBT size
        assert total <= psbt_len
        psbt_len = total

    else:
        with SFFile(TXN_INPUT_OFFSET, max_size=psbt_len) as out:
            taste = out.read(10)
            _, output_encoder, _ = psbt_encoding_taster(taste, psbt_len)

    UserAuthorizedAction.cleanup()
    UserAuthorizedAction.active_request = ApproveTransaction(
        psbt_len, input_method="qr", output_encoder=output_encoder,
        miniscript_wallet=miniscript_wallet,
    )
    the_ux.push(UserAuthorizedAction.active_request)

async def ux_visualize_txn(bin_txn):
    # Show the user a signed transaction on-screen.
    # - longer-term we may offer more data about address ownership, etc
    # - be careful not to claim things we cannot prove w/o UTXO from confirmed blocks
    # - .. like the fee, which would be useful
    from ux import ux_show_story
    from io import BytesIO
    from psbt import  calc_txid
    from serializations import CTransaction

    txn = CTransaction()

    try:
        txn.deserialize(BytesIO(bin_txn))

        if (n := len(txn.vin)) == 1:
            msg = '1 input, '
        else: 
            msg = '%d inputs, ' % n

        if (n := len(txn.vout)) == 1:
            msg += '1 output'
        else: 
            msg += '%d outputs' % n

        # add txid
        txid = calc_txid(BytesIO(bin_txn), (0, len(bin_txn)))
        msg += '\n\nTxid:\n' + b2a_hex(txid).decode()

    except Exception as exc:
        # sys.print_exception(exc)
        msg = "Unable to deserialize"

    await ux_show_story(msg, title="Signed Transaction")


async def ux_visualize_bip21(proto, addr, args):
    # Show details of BIP-21 URL
    # - imho, a bare address is a valid BIP-21 URL so we come here too
    # - validate address ownership on request
    from ux import ux_show_story

    msg = show_single_address(addr) + '\n\n'
    args = args or {}

    if 'amount' in args:
        msg += 'Amount: '
        try:
            amt = args.pop('amount')
            whole, frac = amt.split('.', 1)
            frac = int(frac) if frac else 0
            whole = int(whole) if whole else 0
            msg += '%d.%08d BTC\n' % (whole, frac)
        except:
            msg += '(corrupt)\n'

    for fn in ['label', 'message', 'lightning']:
        if fn in args:
            val = args.pop(fn)
            msg += '%s%s: %s\n' % (fn[0].upper(), fn[1:], val)

    if args:
        msg += 'And values for: ' + ', '.join(args)
        msg += "\n"

    msg += '\nPress (1) to verify ownership.'
    
    ch = await ux_show_story(msg, title="Payment Address", escape='1')

    if ch == '1':
        from ownership import OWNERSHIP
        await OWNERSHIP.search_ux(addr, args)


async def qr_msg_sign_done(signature, address, text):
    from ux import ux_show_story
    from msgsign import rfc_signature_template
    from export import export_by_qr

    sig = b2a_base64(signature).decode('ascii').strip()
    while True:
        ch = await ux_show_story("Press ENTER to export signature QR only, "
                                 "(0) to export full RFC template, "
                                 "CANCEL if done.", escape="0")
        if ch == "x": break
        if ch == "y":
            await export_by_qr(sig, "Signature", "U")
        if ch == "0":
            armored_str = "".join(rfc_signature_template(addr=address, msg=text,
                                                             sig=sig))
            await show_bbqr_codes("U", armored_str, "Armored MSG")

async def qr_sign_msg(txt):
    from msgsign import ux_sign_msg

    await ux_sign_msg(txt, approved_cb=qr_msg_sign_done, kill_menu=True)

async def ux_visualize_textqr(txt, maxlen=MSG_SIGNING_MAX_LENGTH):
    # Show simple text. Don't crash on huge things, but be
    # able to show a full xpub.
    from ux import ux_show_story

    txt_len = len(txt)
    escape = "0"
    if txt_len > maxlen:
        escape = None
        txt = txt[0:maxlen] + '...'

    msg = "%s\n\nAbove is text that was scanned. " % txt
    if escape:
        msg += " Press (0) to sign the text. "

    ch = await ux_show_story(title="Simple Text", msg=msg, escape=escape)
    if escape and (ch == "0"):
        await qr_sign_msg(txt)


async def show_bbqr_codes(type_code, data, msg, already_hex=False):
    # Compress, encode and split data, then show it animated...
    # - happily goes to version 40 if needed
    # - needs to pre-render the QR to get animation to be faster
    # - version of first QR is used for all ther others
    # - screen resolution is considered when picking QR version number
    # - data may point to output side of PSRAM area
    # - Should always do zlib compression (because it nearly always helps)
    #    - BUT: need zlib compress (not present) .. delayed for now
    from bbqr import TYPE_LABELS, int2base36, b32encode, num_qr_needed
    from glob import PSRAM, dis
    from ux import ux_wait_keydown
    import uqr

    # put QR shenanigans at offset 1MB after TXN_OUTPUT_OFFSET
    TMP_OFFSET = const(3 * 1024 * 1024)

    assert not PSRAM.is_at(data, TMP_OFFSET)  # output data would be overwritten with our work
    assert type_code in TYPE_LABELS

    dis.fullscreen('Generating BBQr...', .1)

    if already_hex:
        encoding = 'H'
        data_len = len(data) // 2
    else:
        # default to Base32, because always best option
        encoding = '2'
        data_len = len(data)

    # try a few select resolutions (sizes) in order such that we use either single QR
    # or the least-dense option that gives reasonable number of QR's
    target_vers, num_parts, part_size = num_qr_needed(encoding, data_len)

    assert num_parts * part_size >= data_len

    pos = 0
    force_version = 40
    for pkt in range(num_parts):
        # BBQr header
        hdr = 'B$' + encoding + type_code + int2base36(num_parts) + int2base36(pkt)

        assert pos < data_len, (pkt, pos, data_len)
        if already_hex:
            # not encoding, just hex string
            hp = pos*2
            body = data[hp:hp+(part_size*2)]
        else:
            # encode bytes to base32 encoding
            body = b32encode(data[pos:pos+part_size])

        pos += part_size

        # first packet, want to discover a working small value for QR version
        if pkt == 0:
            mnv = 10 if num_parts > 1 else 1
        else:
            mnv = force_version

        # do the hard work
        qr_data = uqr.make(hdr+body, min_version=mnv,
                                    max_version=force_version, encoding=uqr.Mode_ALPHANUMERIC)

        # save the rendered QR
        if pkt == 0:
            # common values for all parts
            scan_w, w, raw = qr_data.packed()
            raw_qr_size = len(raw)
            qr_size = (raw_qr_size + 3) & ~0x3        # align4
            force_version = qr_data.version()
            assert force_version <= target_vers
        else:
            _, _, raw = qr_data.packed()

        PSRAM.write_at(TMP_OFFSET + (qr_size * pkt), qr_size)[0:raw_qr_size] = raw

        del qr_data

        dis.progress_sofar((pkt+1), num_parts)
    
    # display rate (plus time to send to display, etc)
    ms_per_each = 200

    # hide Generating... text
    dis.fullscreen(' ', 1)
    dis.show()

    ch = None
    while not ch:
        for pkt in range(num_parts):
            buf = PSRAM.read_at(TMP_OFFSET + (qr_size * pkt), raw_qr_size)
            dis.draw_qr_display( (scan_w, w, buf), msg, True, None, None, False, 
                                    partial_bar=((pkt, num_parts) if num_parts else None))

            if num_parts == 1:
                # no need for animation
                ch = await ux_wait_keydown()
                break

            # wait for key or animation delay
            ch = await ux_wait_keydown(None, ms_per_each)
            if ch: break

    # after QR drawing, we need to correct some pixels
    dis.real_clear()


# EOF
