#!/usr/bin/env python3
#
# MMGen Wallet, a terminal-based cryptocurrency wallet
# Copyright (C)2013-2024 The MMGen Project <mmgen@tuta.io>
#
# Project source code repository: https://github.com/mmgen/mmgen-wallet
# Licensed according to the terms of GPL Version 3.  See LICENSE for details.

"""
test.cmdtest_d.ct_input: user input tests for the MMGen cmdtest.py test suite
"""

import sys, os

from mmgen.cfg import gc
from mmgen.util import fmt, capfirst, remove_whitespace
from mmgen.wallet import get_wallet_cls

from ..include.common import (
	cfg,
	imsg,
	imsg_r,
	sample_mn,
	get_data_from_file,
	read_from_file,
	strip_ansi_escapes
)
from .common import Ctrl_U, ref_dir
from .ct_base import CmdTestBase
from .input import stealth_mnemonic_entry, user_dieroll_entry

hold_protect_delay = 2 if sys.platform == 'darwin' else None

class CmdTestInput(CmdTestBase):
	'user input'
	networks = ('btc',)
	tmpdir_nums = [1]
	color = True
	cmd_group_in = (
		('subgroup.char', []),
		('subgroup.line', []),
		('subgroup.password', []),
		('subgroup.misc', []),
		('subgroup.wallet', []),
		('subgroup.mnemonic', []),
		('subgroup.dieroll', []),
	)
	cmd_subgroups = {
	'char': (
		'get_char() function',
		('get_char1',                    'get_char()'),
		('get_char2',                    'get_char() [multiple characters]'),
		('get_char3',                    'get_char() [no prompt]'),
		('get_char4',                    'get_char() [utf8]'),
		('get_char_term1',               'get_char() [term, utf8]'),
		('get_char_term2',               'get_char() [term, multiple characters]'),
		('get_char_term3',               'get_char() [term, prehold_protect=False]'),
		('get_char_term4',               'get_char() [term, immed_chars="xyz"]'),
	),
	'line': (
		'line_input() function',
		('line_input',                    'line_input()'),
		('line_input_term1',              'line_input() [term]'),
		('line_input_term2',              'line_input() [term, no hold protect]'),
		('line_input_insert',             'line_input() [inserted text]'),
		('line_input_insert_term1',       'line_input() [inserted text, term]'),
		('line_input_insert_term2',       'line_input() [inserted text, term, no hold protect]'),
		('line_input_edit_term',          'line_input() [edited text, term, utf8]'),
		('line_input_edit_term_insert',   'line_input() [inserted + edited text, term, utf8]'),
		('line_input_erase_term',         'line_input() [inserted + erased text, term]'),
	),
	'password': (
		'password entry via line_input()',
		('password_entry_noecho',         'utf8 password entry'),
		('password_entry_noecho_term',    'utf8 password entry [term]'),
		('password_entry_echo',           'utf8 password entry (echoed)'),
		('password_entry_echo_term',      'utf8 password entry (echoed) [term]'),
	),
	'misc': (
		'miscellaneous user-level UI functions',
		('get_seed_from_stdin',           'reading seed phrase from STDIN'),
	),
	'wallet': (
		'hash preset, password and label entry',
		('get_passphrase_ui',             'hash preset, password and label (wallet.py)'),
		('get_passphrase_cmdline',        'hash preset, password and label (wallet.py - from cmdline)'),
		('get_passphrase_crypto',         'hash preset, password and label (crypto.py)'),
	),
	'mnemonic': (
		'mnemonic entry',
		('mnemonic_entry_mmgen',           'stealth mnemonic entry (mmgen)'),
		('mnemonic_entry_mmgen_minimal',   'stealth mnemonic entry (mmgen - minimal entry mode)'),
		('mnemonic_entry_bip39',           'stealth mnemonic entry (bip39)'),
		('mnemonic_entry_bip39_short',     'stealth mnemonic entry (bip39 - short entry mode)'),
		('mn2hex_interactive_mmgen',       'mn2hex_interactive (mmgen)'),
		('mn2hex_interactive_mmgen_fixed', 'mn2hex_interactive (mmgen - fixed (10-letter) entry mode)'),
		('mn2hex_interactive_bip39',       'mn2hex_interactive (bip39)'),
		('mn2hex_interactive_bip39_short', 'mn2hex_interactive (bip39 - short entry mode (+pad entry))'),
		('mn2hex_interactive_bip39_fixed', 'mn2hex_interactive (bip39 - fixed (4-letter) entry mode)'),
		('mn2hex_interactive_xmr',         'mn2hex_interactive (xmrseed)'),
		('mn2hex_interactive_xmr_short',   'mn2hex_interactive (xmrseed - short entry mode)'),
	),
	'dieroll': (
		'dieroll entry',
		('dieroll_entry',                 'dieroll entry (base6d)'),
		('dieroll_entry_usrrand',         'dieroll entry (base6d) with added user entropy'),
	)
	}

	def get_seed_from_stdin(self):
		self.spawn('', msg_only=True)
		from subprocess import run, PIPE
		cmd = ['python3', 'cmds/mmgen-walletconv', '--in-fmt=words', '--out-fmt=words', '--outdir=test/trash']
		mn = sample_mn['mmgen']['mn']
		run_env = dict(os.environ)
		run_env['MMGEN_TEST_SUITE'] = ''

		# the test can fail the first time if cfg file has changed, so run it twice if necessary:
		for _ in range(2):
			cp = run(cmd, input=mn.encode(), stdout=PIPE, stderr=PIPE, env=run_env)
			if b'written to file' in cp.stderr:
				break

		from mmgen.color import set_vt100
		set_vt100()
		imsg(cp.stderr.decode().strip())
		res = get_data_from_file(cfg, 'test/trash/A773B05C[128].mmwords', silent=True).strip()
		assert res == mn, f'{res} != {mn}'
		return 'ok' if b'written to file' in cp.stderr else 'error'

	def get_passphrase_ui(self):
		t = self.spawn('test/misc/get_passphrase.py', ['--usr-randchars=0', 'seed'], cmd_dir='.')

		# 1 - new wallet, default hp, label;empty pw
		t.expect('accept the default.*: ', '\n', regex=True)

		# bad repeat
		t.expect('new MMGen wallet: ', 'pass1\n')
		t.expect('peat passphrase: ', 'pass2\n')

		# good repeat
		t.expect('new MMGen wallet: ', '\n')
		t.expect('peat passphrase: ', '\n')
		t.expect('mpty pass')

		t.expect('no label: ', '\n')

		t.expect('[][3][No Label]')

		# 2 - new wallet, user-selected hp, pw, label
		t.expect('accept the default.*: ', '1\n', regex=True)

		t.expect('new MMGen wallet: ', 'pass1\n')
		t.expect('peat passphrase: ', 'pass1\n')

		t.expect('no label: ', 'lbl1\n')

		t.expect('[pass1][1][lbl1]')

		# 3 - passchg, nothing changes
		t.expect('new hash preset')
		t.expect('reuse the old value.*: ', '\n', regex=True)
		t.expect('unchanged')

		t.expect('new passphrase.*: ', 'pass1\n', regex=True)
		t.expect('peat new passphrase: ', 'pass1\n')
		t.expect('unchanged')

		t.expect('reuse the label .*: ', '\n', regex=True)
		t.expect('unchanged')

		t.expect('[pass1][1][lbl1]')

		# 4 - passchg, everything changes
		t.expect('new hash preset')
		t.expect('reuse the old value.*: ', '2\n', regex=True)
		t.expect(' changed to')

		t.expect('new passphrase.*: ', 'pass2\n', regex=True)
		t.expect('peat new passphrase: ', 'pass2\n')
		t.expect(' changed')

		t.expect('reuse the label .*: ', 'lbl2\n', regex=True)
		t.expect(' changed to')
		t.expect('[pass2][2][lbl2]')

		# 5 - wallet from file
		t.expect('from file')

		# bad passphrase
		t.expect('passphrase for MMGen wallet: ', 'bad\n')
		t.expect('Trying again')

		# good passphrase
		t.expect('passphrase for MMGen wallet: ', 'reference password\n')
		t.expect('[reference password][1][No Label]')

		return t

	def get_passphrase_cmdline(self):
		with open('test/trash/pwfile', 'w') as fp:
			fp.write('reference password\n')
		t = self.spawn('test/misc/get_passphrase.py', [
			'--usr-randchars=0',
			'--label=MyLabel',
			'--passwd-file=test/trash/pwfile',
			'--hash-preset=1',
			'seed'],
			cmd_dir = '.')
		for _ in range(4):
			t.expect('[reference password][1][MyLabel]')

		return t

	def get_passphrase_crypto(self):
		t = self.spawn('test/misc/get_passphrase.py', ['--usr-randchars=0', 'crypto'], cmd_dir='.')

		# new passwd
		t.expect('passphrase for .*: ', 'x\n', regex=True)
		t.expect('peat passphrase: ', '\n')
		t.expect('passphrase for .*: ', 'pass1\n', regex=True)
		t.expect('peat passphrase: ', 'pass1\n')
		t.expect('[pass1]')

		# existing passwd
		t.expect('passphrase for .*: ', 'pass2\n', regex=True)
		t.expect('[pass2]')

		# hash preset
		t.expect('accept the default .*: ', '0\n', regex=True)
		t.expect('nvalid')
		t.expect('accept the default .*: ', '8\n', regex=True)
		t.expect('nvalid')
		t.expect('accept the default .*: ', '7\n', regex=True)
		t.expect('[7]')

		# hash preset (default)
		t.expect('accept the default .*: ', '\n', regex=True)
		t.expect(f'[{gc.dfl_hash_preset}]')

		return t

	def _input_func(self, func_name, arg_dfls, func_args, text, expect, term, delay=None):
		if term and sys.platform == 'win32':
			return ('skip_warn', 'pexpect_spawn not supported on Windows platform')
		func_args = dict(zip(arg_dfls.keys(), func_args))
		t = self.spawn(
			'test/misc/input_func.py',
			[func_name, repr(func_args)],
			cmd_dir='.',
			pexpect_spawn=term)
		imsg('Parameters:')
		imsg(f'  pexpect_spawn: {term}')
		imsg(f'  sending:       {text!r}')
		imsg(f'  expecting:     {expect!r}')
		imsg('\nFunction args:')
		for k, v in func_args.items():
			imsg('  {:14} {!r}'.format(k+':', v))
		imsg_r('\nScript output: ')
		prompt_add = (func_args['insert_txt'] if term else '') if func_name == 'line_input' else ''
		prompt = func_args['prompt'] + prompt_add
		t.expect('Calling ')
		if prompt:
			t.expect(prompt, text, delay=delay)
		else:
			t.send(text, delay=delay)
		ret = t.expect_getend('  ==> ')
		assert ret == repr(expect), f'Text mismatch! {ret} != {repr(expect)}'
		return t

	def _get_char(self, func_args, text, expect, term):
		arg_dfls = {
			'prompt': '',
			'immed_chars': '',
			'prehold_protect': True,
			'num_bytes': 5,
		}
		return self._input_func('get_char', arg_dfls, func_args, text, expect, term)

	def _line_input(self, func_args, text, expect, term, delay=None):
		arg_dfls = {
			'prompt': '', # positional
			'echo': True,
			'insert_txt': '',
			'hold_protect': True,
		}
		return self._input_func('line_input', arg_dfls, func_args, text+'\n', expect, term, delay=delay)

	def get_char1(self):
		return self._get_char(['prompt> ', '', True, 5], 'x', 'x', False)

	def get_char2(self):
		expect = 'x' if sys.platform == 'win32' else 'xxxxx'
		return self._get_char(['prompt> ', '', True, 5], 'xxxxx', expect, False)

	def get_char3(self):
		return self._get_char(['', '', True, 5], 'x', 'x', False)

	def get_char4(self):
		return self._get_char(['prompt> ', '', True, 2], 'α', 'α', False) # UTF-8, must get 2 bytes

	def get_char_term1(self):
		return self._get_char(['prompt> ', '', True, 2], 'β', 'β', True)  # UTF-8, must get 2 bytes

	def get_char_term2(self):
		return self._get_char(['prompt> ', '', True, 5], 'xxxxx', 'xxxxx', True)

	def get_char_term3(self):
		return self._get_char(['', '', False, 5], 'x', 'x', True)

	def get_char_term4(self):
		return self._get_char(['prompt> ', 'xyz', False, 5], 'x', 'x', True)

	def line_input(self):
		return self._line_input(
			['prompt> ', True, '', True],
			'foo',
			'foo',
			False)

	def line_input_term1(self):
		return self._line_input(
			['prompt> ', True, '', True],
			'foo',
			'foo',
			True,
			hold_protect_delay)

	def line_input_term2(self):
		return self._line_input(
			['prompt> ', True, '', False],
			'foo',
			'foo',
			True)

	def line_input_insert(self):
		return self._line_input(
			['prompt> ', True, 'inserted text', True],
			'foo',
			'foo',
			False)

	def line_input_insert_term1(self):
		if self.skip_for_mac('readline text buffer issues'):
			return 'skip'
		return self._line_input(
			['prompt> ', True, 'foo', True],
			'bar',
			'foobar',
			True,
			hold_protect_delay)

	def line_input_insert_term2(self):
		if self.skip_for_mac('readline text buffer issues'):
			return 'skip'
		return self._line_input(
			['prompt> ', True, 'foo', False],
			'bar',
			'foobar',
			True)

	def line_input_edit_term(self):
		return self._line_input(
			['prompt> ', True, '', True],
			'\b\bφυφυ\b\bβαρ',
			'φυβαρ',
			True,
			hold_protect_delay)

	def line_input_edit_term_insert(self):
		if self.skip_for_mac('readline text buffer issues'):
			return 'skip'
		return self._line_input(
			['prompt> ', True, 'φυφυ', True],
			'\b\bβαρ',
			'φυβαρ',
			True,
			hold_protect_delay)

	def line_input_erase_term(self):
		if self.skip_for_mac('readline text buffer issues'):
			return 'skip'
		return self._line_input(
			['prompt> ', True, 'foobarbaz', True],
			Ctrl_U + 'foobar',
			'foobar',
			True,
			hold_protect_delay)

	def _password_entry(self, prompt, opts=[], term=False):
		if term and sys.platform == 'win32':
			return ('skip_warn', 'pexpect_spawn not supported on Windows platform')
		t = self.spawn('test/misc/input_func.py', opts + ['passphrase'], cmd_dir='.', pexpect_spawn=term)
		imsg(f'Terminal: {term}')
		pw = 'abc-α'
		t.expect(prompt, pw+'\n')
		ret = t.expect_getend('Entered: ')
		assert ret == pw, f'Password mismatch! {ret} != {pw}'
		return t

	winskip_msg = """
		pexpect_spawn not supported on Windows platform
		Perform the following test by hand with non-ASCII password abc-α
		or another password in your native alphabet:

		  test/misc/input_func.py{} passphrase
	"""

	def password_entry_noecho(self, term=False):
		return self._password_entry('Enter passphrase: ', term=term)

	def password_entry_noecho_term(self):
		if self.skip_for_win('no pexpect_spawn'):
			return ('skip_warn', '\n' + fmt(self.winskip_msg.format(''), strip_char='\t'))
		return self.password_entry_noecho(term=True)

	def password_entry_echo(self, term=False):
		return self._password_entry('Enter passphrase (echoed): ', ['--echo-passphrase'], term=term)

	def password_entry_echo_term(self):
		if self.skip_for_win('no pexpect_spawn'):
			return ('skip_warn', '\n' + fmt(self.winskip_msg.format(' --echo-passphrase'), strip_char='\t'))
		return self.password_entry_echo(term=True)

	def _mn2hex(self, fmt, entry_mode='full', mn=None, pad_entry=False, enter_for_dfl=False):
		mn = mn or sample_mn[fmt]['mn'].split()
		t = self.spawn('mmgen-tool', ['mn2hex_interactive', 'fmt='+fmt, 'mn_len=12', 'print_mn=1'])
		from mmgen.mn_entry import mn_entry
		mne = mn_entry(cfg, fmt, entry_mode)
		t.expect(
			'Type a number.*: ',
			('\n' if enter_for_dfl else str(mne.entry_modes.index(entry_mode)+1)),
			regex = True)
		t.expect(r'Using entry mode (\S+)', regex=True)
		mode = strip_ansi_escapes(t.p.match.group(1)).lower()
		assert mode == mne.em.name.lower(), f'{mode} != {mne.em.name.lower()}'
		stealth_mnemonic_entry(t, mne, mn, entry_mode=entry_mode, pad_entry=pad_entry)
		t.expect(sample_mn[fmt]['hex'])
		return t

	def _user_seed_entry(
			self,
			fmt,
			usr_rand    = False,
			out_fmt     = None,
			entry_mode  = 'full',
			mn          = None,
			seedlen_opt = False):

		wcls = get_wallet_cls(fmt_code=fmt)
		wf = os.path.join(ref_dir, f'FE3C6545.{wcls.ext}')
		if wcls.base_type == 'mnemonic':
			mn = mn or read_from_file(wf).strip().split()
		elif wcls.type == 'dieroll':
			mn = mn or list(remove_whitespace(read_from_file(wf)))
			for idx, val in ((5, 'x'), (18, '0'), (30, '7'), (44, '9')):
				mn.insert(idx, val)
		t = self.spawn(
			'mmgen-walletconv',
			['--usr-randchars=10', '--stdout']
			+ (['--seed-len=128'] if seedlen_opt else [])
			+ [f'--in-fmt={fmt}', f'--out-fmt={out_fmt or fmt}']
		)
		t.expect(f'{capfirst(wcls.base_type or wcls.type)} type:.*{wcls.mn_type}', regex=True)
		if not seedlen_opt:
			t.expect(wcls.choose_seedlen_prompt, '1')
			t.expect('(Y/n): ', 'y')
		if wcls.base_type == 'mnemonic':
			t.expect('Type a number.*: ', '6', regex=True)
			t.expect('invalid')
			from mmgen.mn_entry import mn_entry
			mne = mn_entry(cfg, fmt, entry_mode)
			t.expect('Type a number.*: ', str(mne.entry_modes.index(entry_mode)+1), regex=True)
			t.expect(r'Using entry mode (\S+)', regex=True)
			mode = strip_ansi_escapes(t.p.match.group(1)).lower()
			assert mode == mne.em.name.lower(), f'{mode} != {mne.em.name.lower()}'
			stealth_mnemonic_entry(t, mne, mn, entry_mode=entry_mode)
		elif wcls.type == 'dieroll':
			user_dieroll_entry(t, mn)
			if usr_rand:
				t.expect(wcls.user_entropy_prompt, 'y')
				t.usr_rand(10)
			else:
				t.expect(wcls.user_entropy_prompt, 'n')
		if not usr_rand:
			sid_chk = 'FE3C6545'
			sid = t.expect_getend(f'Valid {wcls.desc} for Seed ID')
			sid = strip_ansi_escapes(sid.split(',')[0])
			assert sid_chk in sid, f'Seed ID mismatch! {sid_chk} not found in {sid}'
		t.expect('to confirm: ', 'YES\n')
		return t

	def mnemonic_entry_mmgen_minimal(self):
		from mmgen.mn_entry import mn_entry
		# erase_chars: '\b\x7f'
		m = mn_entry(cfg, 'mmgen', 'minimal')
		np = 2
		mn = (
			'z',
			'aa',
			'1d2ud',
			'fo{}ot{}#'.format('1' * np, '2' * (m.em.pad_max - np)), # substring of 'football'
			'des1p)%erate\n', # substring of 'desperately'
			'#t!(ie',
			'!)sto8o',
			'the123m8!%s',
			'349t(5)rip',
			'di\b\bdesce',
			'cea',
			'bu\x7f\x7fsuic',
			'app\bpl',
			'wd',
			'busy')
		return self._user_seed_entry('words', entry_mode='minimal', mn=mn)
	def mnemonic_entry_mmgen(self):
		return self._user_seed_entry('words', entry_mode='full')
	def mnemonic_entry_bip39(self):
		return self._user_seed_entry('bip39', entry_mode='full')
	def mnemonic_entry_bip39_short(self):
		return self._user_seed_entry('bip39', entry_mode='short')

	def mn2hex_interactive_mmgen(self):
		return self._mn2hex('mmgen', entry_mode='full')
	def mn2hex_interactive_mmgen_fixed(self):
		return self._mn2hex('mmgen', entry_mode='fixed')
	def mn2hex_interactive_bip39(self):
		return self._mn2hex('bip39', entry_mode='full')
	def mn2hex_interactive_bip39_short(self):
		return self._mn2hex('bip39', entry_mode='short', pad_entry=True)
	def mn2hex_interactive_bip39_fixed(self):
		return self._mn2hex('bip39', entry_mode='fixed', enter_for_dfl=True)
	def mn2hex_interactive_xmr(self):
		return self._mn2hex('xmrseed', entry_mode='full')
	def mn2hex_interactive_xmr_short(self):
		return self._mn2hex('xmrseed', entry_mode='short')

	def dieroll_entry(self):
		return self._user_seed_entry('dieroll', seedlen_opt=True)
	def dieroll_entry_usrrand(self):
		return self._user_seed_entry('dieroll', usr_rand=True, out_fmt='bip39')