#!/usr/bin/env python3
#
# MMGen Wallet, a terminal-based cryptocurrency wallet
# Copyright (C)2013-2025 The MMGen Project <mmgen@tuta.io>
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program.  If not, see <http://www.gnu.org/licenses/>.

"""
opts: command-line options processing for the MMGen Project
"""

import sys, os, re
from collections import namedtuple

from .cfg import gc

def negated_opts(opts, data={}):
	if data:
		return data
	else:
		data.update(dict(
			((k[3:] if k.startswith('no-') else f'no-{k}'), v)
				for k, v in opts.items()
					if len(k) > 1 and not v.has_parm))
		return data

def get_opt_by_substring(opt, opts):
	matches = [o for o in opts if o.startswith(opt)]
	if len(matches) == 1:
		return matches[0]
	if len(matches) > 1:
		from .util import die
		die('CmdlineOptError', f'--{opt}: ambiguous option (not unique substring)')

def process_uopts(cfg, opts_data, opts, need_proto):

	from .util import die

	def get_uopts():
		nonlocal uargs
		idx = 1
		argv_len = len(sys.argv)
		while idx < argv_len:
			arg = sys.argv[idx]
			if len(arg) > 4096:
				raise RuntimeError(f'{len(arg)} bytes: command-line argument too long')
			if arg.startswith('--'):
				if len(arg) == 2:
					uargs = sys.argv[idx+1:]
					return
				opt, parm = arg[2:].split('=', 1) if '=' in arg else (arg[2:], None)
				if len(opt) < 2:
					die('CmdlineOptError', f'--{opt}: option name must be at least two characters long')
				if (
						(_opt := opt) in opts
						or (_opt := get_opt_by_substring(_opt, opts))):
					if opts[_opt].has_parm:
						if parm:
							yield (opts[_opt].name, parm)
						else:
							idx += 1
							if idx == argv_len or (parm := sys.argv[idx]).startswith('-'):
								die('CmdlineOptError', f'missing parameter for option --{_opt}')
							yield (opts[_opt].name, parm)
					else:
						if parm:
							die('CmdlineOptError', f'option --{_opt} requires no parameter')
						yield (opts[_opt].name, True)
				elif (
						(_opt := opt) in negated_opts(opts)
						or (_opt := get_opt_by_substring(_opt, negated_opts(opts)))):
					if parm:
						die('CmdlineOptError', f'option --{_opt} requires no parameter')
					yield (negated_opts(opts)[_opt].name, False)
				elif (
						need_proto
						and (not gc.cmd_caps or gc.cmd_caps.rpc)
						and any(opt.startswith(coin + '-') for coin in gc.rpc_coins)):
					opt_name = opt.replace('-', '_')
					from .protocol import init_proto
					try:
						refval = init_proto(cfg, opt.split('-', 1)[0], return_cls=True).get_opt_clsval(cfg, opt_name)
					except AttributeError:
						die('CmdlineOptError', f'--{opt}: unrecognized option')
					else:
						if refval is None: # None == no parm
							if parm:
								die('CmdlineOptError', f'option --{opt} requires no parameter')
							yield (opt_name, True)
						else:
							from .cfg import conv_type
							if parm:
								yield (opt_name,
									conv_type(opt_name, parm, refval, src='cmdline'))
							else:
								idx += 1
								if idx == argv_len or (parm := sys.argv[idx]).startswith('-'):
									die('CmdlineOptError', f'missing parameter for option --{opt}')
								yield (opt_name,
									conv_type(opt_name, parm, refval, src='cmdline'))
				else:
					die('CmdlineOptError', f'--{opt}: unrecognized option')
			elif arg[0] == '-' and len(arg) > 1:
				for j, sopt in enumerate(arg[1:], 2):
					if sopt in opts:
						if opts[sopt].has_parm:
							if arg[j:]:
								yield (opts[sopt].name, arg[j:])
							else:
								idx += 1
								if idx == argv_len or (parm := sys.argv[idx]).startswith('-'):
									die('CmdlineOptError', f'missing parameter for option -{sopt}')
								yield (opts[sopt].name, parm)
							break
						else:
							yield (opts[sopt].name, True)
					else:
						die('CmdlineOptError', f'-{sopt}: unrecognized option')
			else:
				uargs = sys.argv[idx:]
				return
			idx += 1

	uargs = []
	uopts = dict(get_uopts())

	return uopts, uargs

cmd_opts_v1_pat      = re.compile(r'^-([a-zA-Z0-9-]), --([a-zA-Z0-9-]{2,64})(=| )(.+)')

cmd_opts_v2_pat      = re.compile(r'^\t\t\t(.)(.) -([a-zA-Z0-9-]), --([a-z0-9-]{2,64})(=| )(.+)')
cmd_opts_v2_help_pat = re.compile(r'^\t\t\t(.)(.) (?:-([a-zA-Z0-9-]), --([a-z0-9-]{2,64})(=| ))?(.+)')

global_opts_pat      = re.compile(r'^\t\t\t(.)(.) --([a-z0-9-]{2,64})(=| )(.+)')
global_opts_help_pat = re.compile(r'^\t\t\t(.)(.) (?:--([{}a-zA-Z0-9-]{2,64})(=| ))?(.+)')

opt_tuple = namedtuple('cmdline_option', ['name', 'has_parm'])

def parse_opts(cfg, opts_data, global_opts_data, global_filter_codes, *, need_proto):

	def parse_v1():
		for line in opts_data['text']['options'].strip().splitlines():
			if m := cmd_opts_v1_pat.match(line):
				ret = opt_tuple(m[2].replace('-', '_'), m[3] == '=')
				yield (m[1], ret)
				yield (m[2], ret)

	def parse_v2():
		cmd_filter_codes = opts_data['filter_codes']
		coin_codes = global_filter_codes.coin
		for line in opts_data['text']['options'].splitlines():
			m = cmd_opts_v2_pat.match(line)
			if m and (coin_codes is None or m[1] in coin_codes) and m[2] in cmd_filter_codes:
				ret = opt_tuple(m[4].replace('-', '_'), m[5] == '=')
				yield (m[3], ret)
				yield (m[4], ret)

	def parse_global():
		coin_codes = global_filter_codes.coin
		cmd_codes = global_filter_codes.cmd
		for line in global_opts_data['text']['options'].splitlines():
			m = global_opts_pat.match(line)
			if m and (
					(coin_codes is None or m[1] in coin_codes) and
					(cmd_codes is None or m[2] in cmd_codes)):
				yield (m[3], opt_tuple(m[3].replace('-', '_'), m[4] == '='))

	opts = tuple((parse_v2 if 'filter_codes' in opts_data else parse_v1)()) + tuple(parse_global())

	uopts, uargs = process_uopts(cfg, opts_data, dict(opts), need_proto)

	return namedtuple('parsed_cmd_opts', ['user_opts', 'cmd_args', 'opts'])(
		uopts, # dict
		uargs, # list, callers can pop
		tuple(v.name for k, v in opts if len(k) > 1)
	)

def opt_preproc_debug(po):
	d = (
		('Cmdline',            ' '.join(sys.argv), False),
		('Filtered opts',      po.filtered_opts,   False),
		('User-selected opts', po.user_opts,       False),
		('Cmd args',           po.cmd_args,        False),
		('Opts',               po.opts,            True),
	)
	from .util import Msg, fmt_list
	Msg('\n=== opts.py debug ===')
	for label, data, pretty in d:
		Msg('    {:<20}: {}'.format(label, '\n' + fmt_list(data, fmt='col', indent=' '*8) if pretty else data))

opts_data_dfl = {
	'text': {
		'desc': '',
		'usage':'[options]',
		'options': """
-h, --help         Print this help message
--, --longhelp     Print help message for long (global) options
"""
	}
}

def get_coin():
	for n, arg in enumerate(sys.argv[1:]):
		if len(arg) > 4096:
			raise RuntimeError(f'{len(arg)} bytes: command-line argument too long')
		if arg.startswith('--coin='):
			return arg.removeprefix('--coin=').lower()
		if arg == '--coin':
			if len(sys.argv) < n + 3:
				from .util import die
				die('CmdlineOptError', f'{arg}: missing parameter')
			return sys.argv[n + 2].lower()
		if arg == '-' or not arg.startswith('-'): # stop at first non-option
			return 'btc'
	return 'btc'

class Opts:

	def __init__(
			self,
			cfg,
			*,
			opts_data,
			init_opts,    # dict containing opts to pre-initialize
			parsed_opts,
			need_proto):

		if len(sys.argv) > 257:
			raise RuntimeError(f'{len(sys.argv) - 1}: too many command-line arguments')

		opts_data = opts_data or opts_data_dfl

		self.global_filter_codes = self.get_global_filter_codes(need_proto)
		self.opts_data = opts_data

		po = parsed_opts or parse_opts(
			cfg,
			opts_data,
			self.global_opts_data,
			self.global_filter_codes,
			need_proto = need_proto)

		cfg._args = po.cmd_args
		cfg._uopts = uopts = po.user_opts

		if init_opts: # initialize user opts to given value
			for uopt, val in init_opts.items():
				if uopt not in uopts:
					uopts[uopt] = val

		cfg._opts = self
		cfg._parsed_opts = po
		cfg._use_env = True
		cfg._use_cfg_file = not 'skip_cfg_file' in uopts

		# Make these available to usage():
		cfg._usage_data = opts_data['text'].get('usage2') or opts_data['text']['usage']
		cfg._usage_code = opts_data.get('code', {}).get('usage')
		cfg._help_pkg = self.help_pkg

		if os.getenv('MMGEN_DEBUG_OPTS'):
			opt_preproc_debug(po)

		for funcname in self.info_funcs:
			if funcname in uopts:
				import importlib
				getattr(importlib.import_module(self.help_pkg), funcname)(cfg) # exits

class UserOpts(Opts):

	help_pkg = 'mmgen.help'
	info_funcs = ('version', 'show_hash_presets', 'list_daemon_ids')

	global_opts_data = {
		#  coin code : cmd code : opt : opt param : text
		'text': {
			'options': """
			-- --accept-defaults      Accept defaults at all prompts
			hp --cashaddr=0|1         Display addresses in cashaddr format (default: 1)
			-c --coin=c               Choose coin unit. Default: BTC. Current choice: {cu_dfl}
			er --token=t              Specify an ERC20 token by address or symbol
			-- --color=0|1            Disable or enable color output (default: 1)
			-- --columns=N            Force N columns of output with certain commands
			Rr --scroll               Use the curses-like scrolling interface for
			+                         tracking wallet views
			-- --force-256-color      Force 256-color output when color is enabled
			-- --pager                Pipe output of certain commands to pager (WIP)
			-- --data-dir=path        Specify {pnm} data directory location
			rr --daemon-data-dir=path Specify coin daemon data directory location
			Rr --daemon-id=ID         Specify the coin daemon ID
			rr --ignore-daemon-version Ignore coin daemon version check
			Rr --list-daemon-ids      List all available daemon IDs
			xr --http-timeout=t       Set HTTP timeout in seconds for JSON-RPC connections
			-- --no-license           Suppress the GPL license prompt
			Rr --rpc-host=HOST        Communicate with coin daemon running on host HOST
			rr --rpc-port=PORT        Communicate with coin daemon listening on port PORT
			br --rpc-user=USER        Authenticate to coin daemon using username USER
			br --rpc-password=PASS    Authenticate to coin daemon using password PASS
			Rr --rpc-backend=backend  Use backend 'backend' for JSON-RPC communications
			Rr --aiohttp-rpc-queue-len=N Use N simultaneous RPC connections with aiohttp
			-p --regtest=0|1          Disable or enable regtest mode
			-- --testnet=0|1          Disable or enable testnet
			-- --test-suite           Use test suite configuration
			br --tw-name=NAME         Specify alternate name for the BTC/LTC/BCH tracking
			+                         wallet (default: ‘{tw_name}’)
			-- --skip-cfg-file        Skip reading the configuration file
			-- --version              Print version information and exit
			-- --usage                Print usage information and exit
			b- --bob                  Specify user ‘Bob’ in MMGen regtest mode
			b- --alice                Specify user ‘Alice’ in MMGen regtest mode
			b- --carol                Specify user ‘Carol’ in MMGen regtest mode
			rr COIN-SPECIFIC OPTIONS:
			rr   For descriptions, refer to the non-prefixed versions of these options above
			rr   Prefixed options override their non-prefixed counterparts
			rr   OPTION                            SUPPORTED PREFIXES
			Rr --PREFIX-daemon-id                btc ltc bch eth etc
			rr --PREFIX-ignore-daemon-version    btc ltc bch eth etc xmr
			br --PREFIX-tw-name                  btc ltc bch
			Rr --PREFIX-rpc-host                 btc ltc bch eth etc
			rr --PREFIX-rpc-port                 btc ltc bch eth etc xmr
			br --PREFIX-rpc-user                 btc ltc bch
			br --PREFIX-rpc-password             btc ltc bch
			Rr --PREFIX-max-tx-fee               btc ltc bch eth etc
			Rr PROTO-SPECIFIC OPTIONS:
			Rr   Option                            Supported Prefixes
			Rr --PREFIX-chain-names              eth-mainnet eth-testnet etc-mainnet etc-testnet
			""",
		},
		'code': {
			'options': lambda proto, help_notes, s: s.format(
				pnm     = gc.proj_name,
				cu_dfl  = proto.coin,
				tw_name = help_notes('dfl_twname')),
		}
	}

	@staticmethod
	def get_global_filter_codes(need_proto):
		"""
		Enable options based on the value of --coin and name of executable

		Both must produce a matching code list, or None, for the option to be enabled

		Coin codes:
		  'b' - Bitcoin or Bitcoin code fork supporting RPC
		  'R' - Bitcoin or Ethereum code fork supporting RPC
		  'e' - Ethereum or Ethereum code fork
		  'h' - Bitcoin Cash
		  'r' - local RPC coin
		  'X' - remote RPC coin
		  'x' - local or remote RPC coin
		  '-' - any coin
		Cmd codes:
		  'p' - proto required
		  'c' - proto required, --coin recognized
		  'r' - RPC required
		  '-' - no capabilities required
		"""
		ret = namedtuple('global_filter_codes', ['coin', 'cmd'])
		if caps := gc.cmd_caps:
			coin = get_coin() if caps.use_coin_opt else None
			# a return value of None removes the filter, enabling all options for the given criterion
			return ret(
				coin = caps.coin_codes or (
					None if coin is None else
					['-', 'r', 'R', 'b', 'h', 'x'] if coin == 'bch' else
					['-', 'r', 'R', 'b', 'x'] if coin in gc.btc_fork_rpc_coins else
					['-', 'r', 'R', 'e', 'x'] if coin in gc.eth_fork_coins else
					['-', 'r', 'x'] if coin in gc.local_rpc_coins else
					['-', 'X', 'x'] if coin in gc.remote_rpc_coins else
					['-']),
				cmd = (
					['-']
					+ (['r'] if caps.rpc else [])
					+ (['p', 'c'] if caps.proto and caps.use_coin_opt else ['p'] if caps.proto else [])
				))
		else: # unmanaged command: enable everything
			return ret(None, None)
