import ntpath
import sys
import copy
import argparse
import os
import multiprocessing
import json
import yaml
from typing import Callable, Tuple, List, Optional

from . import timeparse
from .custom_sampling import SamplingCustomizer
import ringo

# TODO: Platform-depedent epilog
BUG_MESSAGE = "If you have questions or found a bug, rerun with -V and report to https://t.me/Nkrivoshchapov 😉"
HELP_EPILOG = f"""\
Usage examples:
    ./mcr pdb_1NWX.sdf result.xyz -P 4 -L 10m
    ./mcr pdb_1NWX.sdf result.xyz --maxconf=1000 -L 15m
{BUG_MESSAGE}
"""

DEFAULT_SETTINGS = {
    'nproc': 1,
    'rmsdvalue': 0.2,
    'vdwfactor': 0.5,
    'ignore_elements': ['HCarbon'],
}

ALLOWED_EXTENSIONS = {
    'inputfile': ('.sdf', '.mol'),
    'resfile': ('.xyz', '.sdf', '.mol'),
}


def result_format(result_file):
    # Checks if the extension is allowed are done separately
    if result_file.lower().endswith('.xyz'):
        return 'xyz'
    else:
        return 'sdf'


ENSEMBLE_DUMP_CALL = {
    'xyz':
    lambda p, res_path, **kwargs: p.save_xyz(res_path),
    'sdf':
    lambda p, res_path, molname, **kwargs: p.save_sdf(res_path,
                                                      molname=molname),
}

DESCRIPTION_POSTPROCESSING = lambda m: f"#{m.idx + 1}. Conformation generated by MCR. {m.descr}"

DIHEDRAL_CONFIGURATION_TO_FLOAT = {
    'cis': 0.0,
    'trans': 180.0,
    'Z': 0.0,
    'E': 180.0,
}


def dihedral_configuration_mapping(user_input: str | float | int) -> float:
    if isinstance(user_input, str):
        assert user_input in DIHEDRAL_CONFIGURATION_TO_FLOAT, \
            f"Provided dihedral configuration '{user_input}' is not known. Specify dihedral using its value or one of available shortcuts: "\
            f"{', '.join(repr(key) for key in DIHEDRAL_CONFIGURATION_TO_FLOAT.keys())}"
        return DIHEDRAL_CONFIGURATION_TO_FLOAT[user_input]
    else:
        assert isinstance(user_input, float) or isinstance(user_input, int), \
            f"Provided dihedral configuration '{user_input}' is not a float/int type"
        return float(user_input)


# RELEASE_PRINTING = False
RELEASE_PRINTING = True


def repr_flags(flags):
    return '/'.join(flags)


def is_float(input):
    try:
        float(input)
        return True
    except:
        return False


def parse_verbosity(settings, parsed_args, **kwargs):
    settings['verbose_mode'] = parsed_args['verbose_flag']


def log(message, settings):
    if settings['verbose_mode']:
        print('\n'.join([f"INFO: {i}"
                         for i in message.split('\n')]))  # f"INFO: {message}"


def print_warnings_default(modename,
                           older_warnings=None,
                           actually_print=True,
                           skip_warnings=[]):
    warnings = [
        warning
        for i, warning in enumerate(ringo.get_status_feed(important_only=True))
        if i not in skip_warnings
    ]
    if older_warnings is not None:
        warnings = [
            warning for i, warning in enumerate(warnings)
            if warning not in older_warnings
        ]

    if len(warnings) > 0 and actually_print:
        print('=====================')
        print("| Please, study these {} warnings carefully:\n{}"\
            .format(modename, '\n'.join([
                f"| {i}) {item['message']}\n| Atoms = {repr(item['atoms'])}"
                if 'atoms' in item
                else f"| {i}) {item['message']}"
                for i, item in enumerate(warnings, start=1)
            ]))
        )
        print('=====================')
    return warnings


get_warnings_skip_printing = lambda *a, **kw: print_warnings_default(
    *a, **kw, actually_print=False)


def print_stats(results: dict, run_settings: dict) -> None:
    stats_descriptions = ringo.mcr_result_to_list(results)
    sections_order = ('total', 'good', 'bad')
    assert all(key in stats_descriptions for key in sections_order), \
        f"Unable to build representation of MCR results statistics: {repr(results)}"

    max_value = max(value for section_name in sections_order
                    for value in stats_descriptions[section_name].values())
    max_len = len(str(max_value))
    sep_line = ''.join(['-'] * max_len)
    lines = [sep_line]

    for section_name in sections_order:
        for description, value in stats_descriptions[section_name].items():
            # Do not show empty entries
            if section_name == 'bad' and value == 0:
                continue
            # The main part - include entry line
            value_str = ''.join([' '] *
                                (max_len - len(str(value)))) + str(value)
            lines.append(f"{value_str} - {description}")
        lines.append(sep_line)

    for line in lines:
        log(line, run_settings)


def parse_file_names(settings, parsed_args, **kwargs):
    # Check for expected file extensions. Case-insensitive to file extensions
    assert any(
            parsed_args['start_sdf'].lower().endswith(ext)
            for ext in ALLOWED_EXTENSIONS['inputfile']
        ), \
        f"Name of the starting SDF-/MOL-file must end with {' or '.join(ext for ext in ALLOWED_EXTENSIONS['inputfile'])}"

    assert any(
            parsed_args['result_file'].lower().endswith(ext)
            for ext in ALLOWED_EXTENSIONS['resfile']
        ), \
        f"Name of the resulting ensemble file must end with {' or '.join(ext for ext in ALLOWED_EXTENSIONS['resfile'])}"

    # Check if input file exists
    settings['wd'] = os.getcwd()
    if os.path.isabs(parsed_args['start_sdf']):
        settings['input_file'] = parsed_args['start_sdf']
    else:
        settings['input_file'] = os.path.normpath(
            os.path.join(settings['wd'], parsed_args['start_sdf']))
        log(
            f"{parsed_args['start_sdf']} was deduced to {settings['input_file']}",
            settings)
    log(f"Using {settings['input_file']} as input file", settings)
    assert os.path.isfile(
        settings['input_file']
    ), f"Cannot find the input file {settings['input_file']}"
    log(f"{settings['input_file']} was located", settings)

    if os.path.isabs(parsed_args['result_file']):
        settings['result_file'] = parsed_args['result_file']
    else:
        settings['result_file'] = os.path.normpath(
            os.path.join(settings['wd'], parsed_args['result_file']))
        log(
            f"{parsed_args['result_file']} was deduced to {settings['result_file']}",
            settings)
    log(f"Will save results in {settings['result_file']}", settings)

    # Decide if the user wants to get XYZ or SDF
    settings['result_file_type'] = result_format(settings['result_file'])
    log(
        f"The format of resulting file was automatically deduced to {settings['result_file_type'].upper()}",
        settings)

    # SDF includes molecule name along with description, so we take this name from input file
    if settings['result_file_type'] == 'sdf':
        settings['molecule_alias'] = '.'.join(
            ntpath.basename(settings['input_file']).split('.')[:-1])
        log(
            f"Will use '{settings['molecule_alias']}' as an alias for this molecule when saving SDF",
            settings)
    else:
        settings['molecule_alias'] = None
    settings['save_ensemble'] = lambda p: \
        ENSEMBLE_DUMP_CALL[settings['result_file_type']](p, settings['result_file'], molname=settings['molecule_alias'])


def parse_termination_criteria(settings, parsed_args, argname_to_flag,
                               **kwargs):
    settings['termination_criteria'] = {}

    if parsed_args['timelimit'] is not None:
        if is_float(parsed_args['timelimit']):
            parsed_time = float(parsed_args['timelimit'])
        else:
            parsed_time = timeparse.timeparse(parsed_args['timelimit'])
        log(
            f"Deduced time limit from '{parsed_args['timelimit']}' to {parsed_time} seconds",
            settings)
        assert parsed_time > 0, f"{parsed_time} does not represent any time interval"
        settings['termination_criteria']['timelimit'] = parsed_time
        log(f"Using {parsed_time} seconds time limit for MCR sampling run",
            settings)

    if parsed_args['maxconf'] is not None:
        assert isinstance(parsed_args['maxconf'], int)
        settings['termination_criteria']['max_conformers'] = parsed_args[
            'maxconf']
        log(
            f"Will generate a maximum of {settings['termination_criteria']['max_conformers']} conformers",
            settings)

    if parsed_args['maxtries'] is not None:
        assert isinstance(parsed_args['maxtries'], int)
        settings['termination_criteria']['max_tries'] = parsed_args['maxtries']
        log(
            f"Will attempt a maximum of {settings['termination_criteria']['max_tries']} sampling iterations",
            settings)

    log(
        f"{len(settings['termination_criteria'])} termination criterion(a) provided: "
        + repr(list(settings['termination_criteria'].keys())), settings)
    assert len(settings['termination_criteria']) > 0, \
        "Must specify at least one termination condition: " \
        f"(1) time limit ({repr_flags(argname_to_flag['timelimit'])}), " \
        f"(2) max conformers generated ({repr_flags(argname_to_flag['maxconf'])}), or " \
        f"(3) max Monte-Carlo trials ({repr_flags(argname_to_flag['maxtries'])}) "


def parse_nthreads(settings, parsed_args, **kwargs):
    if parsed_args['nproc'] is None:
        log("Number of threads to use was not specified", settings)
        settings['nproc'] = None
        return

    threads_available = multiprocessing.cpu_count()
    log(
        f"The requested number of threads={parsed_args['nproc']}, threads available = {threads_available}",
        settings)
    assert parsed_args[
        'nproc'] >= 1, f"Unreasonable number of threads ({parsed_args['nproc']} is less than one)"
    assert parsed_args['nproc'] <= threads_available, \
        f"The requested number of threads ({parsed_args['nproc']}) is greater " \
        f"than the number of threads available ({threads_available})"
    settings['nproc'] = parsed_args['nproc']
    log(f"Will use {settings['nproc']} threads", settings)


def parse_rmsd_settings(settings, parsed_args, argname_to_flag, **kwargs):
    rmsd_options = {
        'threshold': None,
        'mirror_match':
        not parsed_args['mirror_flag'],  # This flag disables mirror match
    }
    isomorphisms_options = {
        'ignore_elements': None,
    }

    if parsed_args['rmsdvalue'] is not None:
        rmsd_options['threshold'] = parsed_args['rmsdvalue']
        log(f"Custom RMSD threshold was provided: {rmsd_options['threshold']}",
            settings)
    else:
        log("Custom RMSD threshold was not provided", settings)

    if parsed_args['ignore_elements'] is not None:
        log(
            f"Custom elements to be ignored were provided: {parsed_args['ignore_elements']}",
            settings)
        isomorphisms_options['ignore_elements'] = json.loads(
            parsed_args['ignore_elements'])
        assert isinstance(
            isomorphisms_options['ignore_elements'],
            list), "Expected a *list* of one or several element (or indices)"
        if len(isomorphisms_options['ignore_elements']) == 0:
            log(f"Will not ignore any elements", settings)
        else:
            log(
                f"These atoms will be ignored during RMSD calcs: {repr(isomorphisms_options['ignore_elements'])}",
                settings)
    else:
        log("Custom elements to be ignored were not provided", settings)

    settings['rmsd_settings'] = {
        'isomorphisms': isomorphisms_options,
        'rmsd': rmsd_options,
    }


def parse_amide_settings(settings, parsed_args, argname_to_flag, **kwargs):
    amide_configuration_default: Optional[str] = parsed_args[
        'amide_configuration']

    if parsed_args['settings_yaml'] is None:
        user_settings_data = {}
    else:
        assert os.path.isfile(parsed_args['settings_yaml']), \
            f"Settings file '{parsed_args['settings_yaml']}' is not found"
        with open(parsed_args['settings_yaml'], 'r') as f:
            user_settings_data = yaml.safe_load(f)

    def _p(key: str,
           user_input: dict,
           default_value=None,
           *,
           sync_type=True,
           assert_presence=False):
        if key in user_input:
            if sync_type:
                return type(default_value)(user_input[key])
            else:
                return user_input[key]
        else:
            if assert_presence:
                raise RuntimeError(
                    f"Key '{key}' was not provided in the settings block '{user_input}' (error in file '{parsed_args['settings_yaml']}')"
                )
            return default_value

    def format_required_dihedral_values(
        provided_configurations: float | int | str | List[float] | List[int]
        | List[str]
    ) -> List[float]:
        if provided_configurations == 'flexible':
            provided_configurations = []  # Flexiblity = absence of constraints
        else:
            assert 'flexible' not in provided_configurations, \
                f"Found 'flexible' in provided configurations '{provided_configurations}'. Should either use just 'flexible' or not mention it at all."

        if isinstance(provided_configurations, str):
            # 'cis' => ['cis'] to be compatible with ['cis', 'trans']
            provided_configurations = [provided_configurations]

        result: List[float] = [
            dihedral_configuration_mapping(value)  # 'cis' => 0.0, etc.
            for value in provided_configurations
        ]
        assert len(set(result)) == len(result), \
            f"Redundant values are found in the list of requested dihedral configurations {result}, the original input is {provided_configurations}"
        return result

    sampling_rules_main = _p('sampling_rules', user_settings_data, {})
    sampling_width_default = _p('sampling_width_default', sampling_rules_main,
                                20.0)
    mandatory_default = _p('mandatory_default', sampling_rules_main, False)
    filtering_width_default = _p('filtering_width_default',
                                 sampling_rules_main, sampling_width_default)
    amide_treat_main = _p('amide_dihedrals', sampling_rules_main, {})
    assert not(amide_configuration_default is not None and 'allowed_configurations' in amide_treat_main), \
        f"Amide configuration can be requsted using either CLI (flag '{argname_to_flag['amide_configuration']}') or YAML configuration file, but not both!"

    if amide_configuration_default is None:
        amide_configuration_default = 'cis'
    if ',' in amide_configuration_default:
        amide_configuration_default: List[
            str] = amide_configuration_default.split(',')

    resuting_dict = {
        'amide_treat': {
            'allowed_configurations':
            format_required_dihedral_values(
                _p('allowed_configurations',
                   amide_treat_main,
                   amide_configuration_default,
                   sync_type=False)),
            'sampling_width':
            _p('sampling_width', amide_treat_main, sampling_width_default),
            'mandatory':
            _p('mandatory', amide_treat_main, mandatory_default),
            'filtering_width':
            _p('filtering_width', amide_treat_main, filtering_width_default),
        },
        'special_dihedrals':
        [{
            'bond':
            _p('bond', dihedral_request, sync_type=False,
               assert_presence=True),
            'allowed_configurations':
            format_required_dihedral_values(
                _p('allowed_configurations',
                   dihedral_request,
                   sync_type=False,
                   assert_presence=True)),
            'sampling_width':
            _p('sampling_width', dihedral_request, sampling_width_default),
            'mandatory':
            _p('mandatory', dihedral_request, mandatory_default),
            'filtering_width':
            _p('filtering_width', dihedral_request, filtering_width_default),
        }
         for dihedral_request in _p('specific_rules', sampling_rules_main, [])]
    }

    for special_data in resuting_dict['special_dihedrals']:
        bond = special_data['bond']
        assert (len(bond) == 2 or len(bond) == 4), \
            f"Bond should be represented by 4 atom indices (can use 2 in case of amide bonds). Got '{bond}'"
        assert all((isinstance(i, int) and i >= 1) for i in bond), \
            f"Bond should be represented by positive integer indices. Got '{bond}'"
        special_data['bond'] = [i - 1 for i in bond]

    settings['sampling_rules'] = resuting_dict


def parse_postopt_settings(settings, parsed_args, argname_to_flag, **kwargs):
    main_postopt_enabled_default: bool = parsed_args['postopt_enable']

    if parsed_args['settings_yaml'] is None:
        user_settings_data = {}
    else:
        assert os.path.isfile(parsed_args['settings_yaml']), \
            f"Settings file '{parsed_args['settings_yaml']}' is not found"
        with open(parsed_args['settings_yaml'], 'r') as f:
            user_settings_data = yaml.safe_load(f)

    def _p(key: str, user_input: dict, default_value):
        if key in user_input:
            return type(default_value)(user_input[key])
        else:
            return default_value

    newconf_main = _p('newly_assembled_conformer', user_settings_data, {})
    geoval_newconf = _p('geometry_validation', newconf_main, {})
    overlap_newconf = _p('overlap_check', newconf_main, {})

    postopt_main = _p('postoptimization', user_settings_data, {})
    main_postopt_enabled = _p('enabled', postopt_main,
                              main_postopt_enabled_default)
    if main_postopt_enabled_default and not main_postopt_enabled:
        assert 'settings_yaml' in parsed_args
        raise RuntimeError(
            f"Conflicting input: postoptimization is enabled with flag {argname_to_flag['postopt_enable']}, "
            f"but disabled in the settings YAML ({parsed_args['settings_yaml']})"
        )
    first_p = _p('first_potential', postopt_main, {})
    second_p = _p('second_potential', postopt_main, {})
    geoval_postopt = _p('geometry_validation', postopt_main, {})
    overlap_postopt = _p('overlap_check', postopt_main, {})
    if not main_postopt_enabled:
        settings['postopt_settings'] = [{'enabled': False}, {'enabled': False}]
    else:
        settings['postopt_settings'] = [
            {
                'max_postopt_attempts': _p('max_postopt_attempts',
                                           postopt_main, 5),
                'enabled': _p('enabled', first_p, True),
                'epsilon': _p('epsilon', first_p, 1e-4),
                'vdw_radius_multiplier': _p('vdw_radius_multiplier', first_p,
                                            1.2),
                'max_iterations': _p('max_iterations', first_p, 2000),
                'max_linesearch': _p('max_linesearch', first_p, 40),
                'bond_stretch_weight': _p('bond_stretch_weight', first_p, 100),
                'vangle_weight': _p('vangle_weight', first_p, 10),
                'dihedral_weight': _p('dihedral_weight', first_p, 10),
                'vdw_weight': _p('vdw_weight', first_p, 10),
                'first_overlap_break': _p('first_overlap_break', first_p,
                                          False),
                'first_invalid_break': _p('first_invalid_break', first_p,
                                          False),
            },
            {
                'enabled': _p('enabled', second_p, True),
                'epsilon': _p('epsilon', second_p, 1e-4),
                'vdw_radius_multiplier': _p('vdw_radius_multiplier', second_p,
                                            1.2),
                'max_iterations': _p('max_iterations', second_p, 500),
                'max_linesearch': _p('max_linesearch', second_p, 40),
                'bond_stretch_weight': _p('bond_stretch_weight', second_p,
                                          500),
                'vangle_weight': _p('vangle_weight', second_p, 20),
                'dihedral_weight': _p('dihedral_weight', second_p, 20),
                'vdw_weight': _p('vdw_weight', second_p, 10),
            },
        ]

    settings['geometry_validation_settings'] = {
        'ringo': {
            'bondlength': _p('bondlength', geoval_newconf, 0.05),
            'valence': _p('valence', geoval_newconf, 3.0),
            'dihedral': _p('dihedral', geoval_newconf, 3.0),
        },
        'postopt': {
            'bondlength': _p('bondlength', geoval_postopt, 0.2),
            'valence': _p('valence', geoval_postopt, 20),
            'dihedral': _p('dihedral', geoval_postopt, 20),
            'tweak_angles': True,
        }
    }

    settings['overlap_check_settings'] = {
        'ringo_vdwfactor': _p('vdw_radius_multiplier', overlap_newconf, 0.5),
        'postopt_vdwfactor': _p('vdw_radius_multiplier', overlap_postopt,
                                0.75),
    }


def set_defaults(settings, **kwargs):
    if settings['nproc'] is None:
        settings['nproc'] = DEFAULT_SETTINGS['nproc']
        log(f"By default, will use {settings['nproc']} threads", settings)

    if settings['rmsd_settings']['rmsd']['threshold'] is None:
        settings['rmsd_settings']['rmsd']['threshold'] = DEFAULT_SETTINGS[
            'rmsdvalue']
        log(
            f"By default, will use RMSD cutoff of {settings['rmsd_settings']['rmsd']['threshold']} A",
            settings)

    if settings['rmsd_settings']['isomorphisms']['ignore_elements'] is None:
        settings['rmsd_settings']['isomorphisms'][
            'ignore_elements'] = DEFAULT_SETTINGS['ignore_elements']
        log(
            f"By default, will ignore these elements during RMDS calcs: {repr(settings['rmsd_settings']['isomorphisms']['ignore_elements'])}",
            settings)


def set_printing_settings(settings, **kwargs):
    settings['print_warnings'] = print_warnings_default
    # if __name__ == "__main__":
    # else:
    #     settings['print_warnings'] = get_warnings_skip_printing


def is_jsonable(x):
    try:
        json.dumps(x)
        return True
    except (TypeError, OverflowError):
        return False


def log_final_settings(settings, **kwargs):
    log(
        "COMPLETE RUN SETTINGS:\n" + json.dumps(
            {
                key: value if is_jsonable(value) else repr(value)
                for key, value in settings.items()
            },
            indent=2) + "\nCLI ARGUMENT PARSING HAS FINISHED\n", settings)


ARGS_PROCESSING_STAGES = [
    parse_verbosity,
    parse_file_names,
    parse_termination_criteria,
    parse_nthreads,
    parse_rmsd_settings,
    parse_amide_settings,
    parse_postopt_settings,
    set_defaults,
    set_printing_settings,
    log_final_settings,
]


def produce_settings(config_data):
    for stage in ARGS_PROCESSING_STAGES:
        stage(**config_data)


def parse_args(argv: list) -> dict:
    parser = argparse.ArgumentParser(
        prog='python -m mcr',
        description=
        'Monte-Carlo with Refinements (MCR) conformational sampling algorithm',
        formatter_class=argparse.RawDescriptionHelpFormatter,
        epilog=HELP_EPILOG)  # usage=HELP_USAGE,

    parser.add_argument(
        'start_sdf',
        type=str,
        help=
        "File containing starting geometry and topology. Must terminate with .sdf or .mol.",
    )
    parser.add_argument(
        'result_file',
        type=str,
        help=
        "Name for the resulting XYZ/SDF file containing the generated conformational ensemble."
    )

    parser.add_argument(
        '-L',
        '--timelimit',
        dest='timelimit',
        type=str,
        help=
        "Time limit for this sampling run in seconds (maybe use format 2m30s, etc.)."
    )
    parser.add_argument('-C',
                        '--maxconf',
                        dest='maxconf',
                        type=int,
                        help="Maximum number of conformers to be generated.")
    parser.add_argument('-T',
                        '--maxtries',
                        dest='maxtries',
                        type=int,
                        help="Maximum number of tries.")
    parser.add_argument(
        '-P',
        '--nproc',
        dest='nproc',
        type=int,
        help=
        "Number of threads to use (currently, does not scale well on more than 8 cores). Default=1"
    )
    parser.add_argument(
        '-R',
        '--rmsd',
        dest='rmsdvalue',
        type=float,
        help="RMSD cutoff for duplicate filtering in Angstroms. Default=0.2")
    parser.add_argument(
        '-E',
        '--exclude',
        dest='ignore_elements',
        type=str,
        help=
        "Exclude certain elements from RMSD calculations during duplicate filtering. Format [\"H\"] or [\"H\", \"F\"]. Note that Bash requires double quotes to be escaped accordingly (\\\"H\\\", etc.) Special element type \"HCarbon\" corresponds to hydrogen atoms bonded to a carbon atom. Default=\"HCarbon\""
    )
    parser.add_argument(
        '-M',
        '--mirror',
        dest='mirror_flag',
        action='store_true',
        help=
        "Consider mirror reflections to be different conformers during duplicate filtering. If -M flag is set, RMSD > 0 for mirror reflections. By default, RMSD is zero for mirror reflections."
    )
    parser.add_argument(
        '-O',
        '--opt',
        dest='postopt_enable',
        action='store_true',
        help=
        "Enable post-optimization of all generated conformations. The -O flag can be skipped when using -S flag to specify additional post-optimization settings"
    )
    parser.add_argument(
        '-A',
        '--amide',
        dest='amide_configuration',
        type=str,
        help=
        "Set global rules for sampling of amide dihedrals. Expecting comma-separated values, e.g., '-A cis,trans', '-A cis', '--amide=E,Z' and so on. Note: global rules are applied only for R-C(=O)-N(H)R dihedrals, since for -N(R)R' cases there is no practical way to tell which one is cis/trans (use settings YAML to set preferences for dihedrals one by one)"
    )
    parser.add_argument(
        '-S',
        '--settings',
        dest='settings_yaml',
        type=str,
        help=
        "Path to YAML file with detailed settings (see example in `default_settings.yaml`). There are too many variables for conformer postoptimization and validation to pass them as flags."
    )
    parser.add_argument(
        '-V',
        '--verbose',
        dest='verbose_flag',
        action='store_true',
        help="Be more verbose on the CLI argument parsing stage.")

    # Organize flag mappings for simpler processing later
    option_string_actions = parser._option_string_actions
    flag_to_argname = {
        option_string: option_string_actions[option_string].dest
        for option_string in option_string_actions
        if option_string.startswith('-')
    }

    argname_to_flag = {}
    for option_string in option_string_actions:
        if option_string.startswith('-'):
            if option_string_actions[option_string].dest in argname_to_flag:
                argname_to_flag[option_string_actions[option_string].
                                dest].append(option_string)
            else:
                argname_to_flag[option_string_actions[option_string].dest] = [
                    option_string
                ]

    parsed_args = parser.parse_args(argv)
    config_data = {
        'parsed_args': vars(parsed_args),
        'settings': {
            'argv': argv
        },
        'argname_to_flag': argname_to_flag,
        'flag_to_argname': flag_to_argname,
    }

    produce_settings(config_data)
    return config_data['settings']


def parse_args_trycatch(release_catch: bool) -> dict:
    argv = copy.copy(sys.argv)[1:]

    if release_catch:
        try:
            return parse_args(argv)
        except Exception as e:
            # exc_type, exc_value, exc_traceback = sys.exc_info()
            # traceback.print_exception(exc_type, exc_value, exc_traceback)
            print(
                f"Failed during keyword parsing: {e} ({type(e).__name__}). P.S. {BUG_MESSAGE}"
            )
            print("Error termination")
            sys.exit(1)
    else:
        return parse_args(argv)


def confsearch_driver(input_file: str, rmsd_settings: dict, nproc: int,
                      termination_criteria: dict, postopt_settings: list,
                      geometry_validation_settings: dict,
                      overlap_check_settings: dict, sampling_rules: dict,
                      print_warnings: Callable,
                      **run_settings) -> Tuple[ringo.Confpool, list, list]:

    ringo.set_radius_multiplier(overlap_check_settings['ringo_vdwfactor'],
                                'ringo')
    ringo.set_radius_multiplier(overlap_check_settings['postopt_vdwfactor'],
                                'postopt')

    init_kwargs = {}
    sampling_customizer = SamplingCustomizer(input_file, sampling_rules)
    fixed_dihedrals = sampling_customizer.get_fixed_bonds()
    if len(fixed_dihedrals) > 0:
        init_kwargs['request_free'] = fixed_dihedrals

    log(f"Performing kinematic analysis for {input_file}", run_settings)
    log(f"Ringo version is {ringo.__version__}", run_settings)
    mol = ringo.Molecule.from_sdf(input_file, **init_kwargs)
    log("Initialization has finished", run_settings)

    sampling_customizer.set_sampling_limits(mol)

    # Print important warnings if there were any
    init_warnings = print_warnings(
        "initialization", skip_warnings=sampling_customizer.warnings_to_skip)
    ringo.clear_status_feed()

    # Create pool for future conformers
    p = ringo.Confpool()

    # Execute MCR sampling
    mcr_kwargs = {
        'rmsd_settings': rmsd_settings,
        'postopt_settings': postopt_settings,
        'nthreads': nproc,
        'geometry_validation': geometry_validation_settings,
        **sampling_customizer.get_filtering_function(),
        **termination_criteria,
    }

    log(f"Executing MCR conformational sampling for {input_file}",
        run_settings)
    results = ringo.run_confsearch(mol, pool=p, **mcr_kwargs)
    log(f"Sampling has finished", run_settings)
    if run_settings['verbose_mode']:
        print_stats(results, run_settings)

    # Can safely validate ensemble when optimization is turned off
    # if not postopt_settings[0]['enabled']:
    #     sampling_customizer.validate_ensemble(p)

    # Create clearer conformer descriptions
    p.descr = DESCRIPTION_POSTPROCESSING

    # Print important warnings if there were any
    mcr_warnings = print_warnings(
        "MCR sampling",
        init_warnings,
        skip_warnings=sampling_customizer.warnings_to_skip)
    ringo.clear_status_feed()

    return p, init_warnings, mcr_warnings


def confsearch_with_warnings(save_ensemble: Callable, result_file: str,
                             result_file_type: str, **run_settings) -> None:
    p, init_warnings, mcr_warnings = confsearch_driver(**run_settings)
    num_warnings = len(init_warnings) + len(mcr_warnings)

    # Save conformational pool to file
    if num_warnings == 0:
        additional_message = ""
    else:
        additional_message = f". However, note the {num_warnings} warning(s) printed above."

    if len(p) > 0:
        if save_ensemble is not None:
            save_ensemble(p)  # 'result_file' in enclosed in this function
            log(
                f"{len(p)} conformations were generated and saved to '{result_file}' ({result_file_type.upper()}-format)",
                run_settings)
        print("Normal termination" + additional_message)
    else:
        print(f"The result file {result_file} will not be created")
        raise RuntimeError(
            'No conformers found within given time limit/number of MC trials')


def confsearch_trycatch(release_catch: bool, **run_settings) -> None:
    if release_catch:
        try:
            confsearch_with_warnings(**run_settings)
        except Exception as e:
            # exc_type, exc_value, exc_traceback = sys.exc_info()
            # traceback.print_exception(exc_type, exc_value, exc_traceback)
            print(f"Error termination: {e} ({type(e).__name__})")
            sys.exit(1)
    else:
        confsearch_with_warnings(**run_settings)


def main(release_catch: bool = RELEASE_PRINTING) -> None:
    run_settings: dict = parse_args_trycatch(release_catch=release_catch)
    confsearch_trycatch(release_catch=release_catch, **run_settings)
