''' opencos.eda_tool_helper -- used by pytests and other checks to see if tools are loaded

which helps determine if a pytest is runnable for a given tool, or should be skipped.
Does this without calling `eda` or eda.main(..)

Example uses:
    from opencos import eda_tool_helper
    cfg, tools_loaded = eda_tool_helper.get_config_and_tools_loaded()
    assert 'verilator' in tools_loaded

'''

import os
import shlex
import subprocess
from importlib import import_module
import importlib.util

from opencos import eda_config, util
from opencos.util import Colors
from opencos.utils import vsim_helper, vscode_helper, docker_checks, str_helpers, exe_helper

# Used by pytest, so we can skip tests if tools aren't present, also used by
# opencos.eda itself

DOCKER_TOOL_NOTE = ' [via docker] '

def tool_loaded_but_flagged_as_docker_only(tool: str, config: dict) -> bool:
    '''Checks if a tool was flagged in config as only being able to run

    via docker (and wasn't availble in PATH, shutil.which(..))
    '''
    exe = config.get('auto_tools_found', {}).get(tool, '')
    return exe and exe.startswith(DOCKER_TOOL_NOTE)


def get_config_and_tools_loaded( # pylint: disable=dangerous-default-value
        quiet: bool = False, args: list = [],
        remove_tools_with_docker_note: bool = True
) -> (dict, set):
    '''Returns config dict and list tools_loaded, given the found config.

    Can BYO args such as --config-yml=MY_OWN_EDA_CONFIG.yml
    '''

    # We have to figure out what tools are avaiable w/out calling eda.main,
    # so we can get some of these using eda_config.get_eda_config()
    config, _ = eda_config.get_eda_config(args=args, quiet=quiet)

    # only import 'eda' here so that other methods in this pymodule can be used
    # within eda, etc, if you already have a valid config or tools_loaded.
    eda = import_module("opencos.eda")
    config = eda.init_config(config=config, quiet=quiet)
    tools_loaded = config.get('tools_loaded', []).copy()

    # Remove any tools from tools_loaded that had their exe flagged with
    # str DOCKER_TOOL_NOTE. This is also for pytests so that heplers.can_run_eda_sim()
    # returns False if it were to otherwise appear that we can run verilator
    # (but only with --docker flag set, that majority of test do not)
    if remove_tools_with_docker_note:
        for _tool in list(tools_loaded):
            if tool_loaded_but_flagged_as_docker_only(tool=_tool, config=config):
                tools_loaded.remove(_tool)

    return config, tools_loaded


def get_all_handler_commands( # pylint: disable=too-many-branches
        config=None, tools_loaded=None, auto_tools_only: bool = False
) -> dict:
    '''Given a config and tools_loaded (or if not supplied uses defaults) returns a dict

    of { <command>: [list of tools that run that command, in auto-tool-order] }.

    If you use auto_tools_only=True, then you will only get command: tools that exist in
    config['auto_tools_order'] (and are loaded tools). This would be what you'd get if you
    ran `eda [command]` and did not set the --tool=TOOL arg, for what can run that command.

    If you use auto_tools_only=False (default), you will get the auto tool order +
    tool handlers (loaded tools only) that can service the command. For example, eda waves,
    eda upload, and others may not be in the auto tool list for an `eda [command]`, because
    those command have to self determine their tool.

    For example:
       { "sim": ["verilator", "vivado"],
         "elab": ["slang", "verilator", ...], ...
       }
    '''
    all_handler_commands = {}


    if config and tools_loaded is None:
        tools_loaded = config.get('tools_loaded', [])
    elif config is None or tools_loaded is None:
        # This will re-run eda.init_config(..), b/c you didn't bring a config.
        config, tools_loaded = get_config_and_tools_loaded()


    assert isinstance(config, dict)
    assert isinstance(tools_loaded, list)

    # Let's re-walk auto_tools_order to get this ordered per eda command:
    for command, tools_list in config.get('auto_tools_order', {}).items():

        for tool in tools_list:
            entry = config.get('tools', {}).get(tool, {})
            assert entry, f'{command=} in auto_tools_order {tool=} not present in tools'

            if tool not in tools_loaded:
                continue

            if entry.get('disable-tools-multi', False):
                # Flagged as do-not-add when running eda command: tools-multi
                util.debug(f'eda_tool_helper.py -- skipping {tool=} it is set with flag',
                           'disable-tools-multi in config')
                continue

            if command in entry.get('handlers', {}):
                if command not in all_handler_commands:
                    # create ordered list from config.
                    all_handler_commands[command] = list([tool])
                else:
                    all_handler_commands[command].append(tool)

    if auto_tools_only:
        # skip the next step and return what we've got.
        return all_handler_commands

    # Note that the above will miss commands (waves, upload) that are not in auto_tools_order
    for tool in tools_loaded:
        handlers = config.get('tools', {}).get(tool, {}).get('handlers', {})
        for command, _ in handlers.items():
            if command not in all_handler_commands:
                all_handler_commands[command] = list([tool])
            elif tool not in all_handler_commands.get(command, []):
                all_handler_commands[command].append(tool)

    return all_handler_commands

def get_handler(tool: str, eda_command: str, config: dict, warn: bool = True):
    '''Get the handling class given tool/command/config'''

    entry = config['tools'].get(tool, {})
    if not entry:
        return ''

    handler_name = entry.get('handlers', {}).get(eda_command, '')
    if not handler_name:
        return ''

    module = util.import_class_from_string(handler_name)
    if module:
        return module

    if warn:
        util.warning(
            f'For {tool=}, command {eda_command}, Handler name {handler_name} is',
            'not a valid class to import'
        )
    return None


def get_handler_tool_version(tool: str, eda_command: str, config: dict) -> str:
    '''Attempts to get a Command Handler's version given tool + eda_command'''

    # TODO(drew): maybe if there are no handlers, then do something to get version?
    # like, offer tool_class?

    module = get_handler(tool=tool, eda_command=eda_command, config=config)
    if not module:
        return ''

    obj = module(config=config)

    # Note that Tool.get_versions() is supposed to be 'fast', we don't always
    # run the tool if the 'exe -version' takes too long.
    if getattr(obj, 'get_versions', None):
        return obj.get_versions()

    return ''



def get_handler_info_with_versions( # pylint: disable=too-many-branches,dangerous-default-value
        config: dict = {},
        include_commands: bool = True,
        sort: bool = True,
        show_versions: bool = True,
        all_details: bool = False
) -> str:
    '''Creates and returns a dict of

    {'commands': (what tools/versions can run them),
     'tools': (what version and what commands they can run)
    }

    for arg 'config' you may use:

        config, tools_loaded = get_config_and_tools_loaded()

    show_versions=True can be overriden by config['show_tool_versions']
    all_details=True will make note of Docker and other information.
    '''

    if not config:
        config, tools_loaded = get_config_and_tools_loaded()
    else:
        tools_loaded = list(config.get('tools_loaded', []))

    eda_commands = list(config.get('DEFAULT_HANDLERS', {}).keys())
    if not config.get('show_tool_versions', True):
        # default true, if user set to False in thier config then respect that
        show_versions = False

    info = {
        'tools': {},
    }

    if include_commands:
        info.update({
            'commands': {}
        })
        for eda_command in eda_commands:
            info['commands'][eda_command] = {} # init

    # set tool version, and commands supported
    for tool in tools_loaded:

        info['tools'][tool] = {}
        if show_versions:
            info['tools'][tool]['version'] = ''
        if include_commands:
            info['tools'][tool]['commands'] = []

        for eda_command in eda_commands:

            ver = ''
            if not show_versions:
                # well then we also don't know if the tool is loaded?
                if not include_commands:
                    continue
                if not get_handler(tool=tool, eda_command=eda_command, config=config,
                                   warn=False):
                    continue

            else:
                if not include_commands and info['tools'][tool]['version']:
                    # version is already set, and we're not doing for all commands:
                    break

                # Note that if you have a generic handler, or a tool that has
                # several handlers with more than one Tool class, that all can return a
                # non blank-str version, you may have problems.
                ver = get_handler_tool_version(
                    tool=tool, eda_command=eda_command, config=config
                )

                if not ver:
                    continue

            info['tools'][tool]['version'] = ver

            if include_commands:
                info['commands'][eda_command][tool] = ver
                info['tools'][tool]['commands'].append(eda_command)

    # set path, and support all_details=True
    for tool, _ in info['tools'].items():
        if path := config.get('auto_tools_found', {}).get(tool, ''):
            info['tools'][tool]['path'] = path

            if all_details:
                if path.startswith(DOCKER_TOOL_NOTE):
                    info['tools'][tool]['docker'] = {
                        'image': path.replace(DOCKER_TOOL_NOTE, '')
                    }

    # return the info dict with 'tools' and 'commands' entries sorted:
    if sort:
        for key in list(info.keys()):
            if info[key]:
                info[key] = dict(sorted(info[key].items()))

    return info


def pretty_info_handler_tools( # pylint: disable=dangerous-default-value
        info: dict = {}, config: dict = {}, command: str = ''
) -> None:
    '''Pretty print (via util.info) the result from get_handler_info_with_versions()

    if info is None or empty, will use config to run get_handler_info_with_versions(..)

    Does not include commands
    '''

    if not info:
        info = get_handler_info_with_versions(config=config, include_commands=False, sort=True)

    if not info.get('tools', {}):
        # No tools detected
        if command and command in config.get('command_tool_is_optional', []):
            # but this command doesn't need tools
            return
        if config.get('ssh', None):
            # For ssh we don't require tools, will be sent to remote, avoid this message.
            return

        # if command omitted, or command may need tools, print that we don't have any
        util.info('No tools detected!', color=Colors.yellow)
        return

    show_versions = any(dvalue.get('version', '') for dvalue in info['tools'].values())

    tools_rows = [
        ['--Detected tool--', '--Path--'] # Header row.
    ]

    if show_versions:
        tools_rows[0].append('--Version--')

    for _tool, dvalue in info['tools'].items():
        path = dvalue.get("path", "")
        if path:
            path = f'({path})'

        if show_versions:
            version = dvalue.get("version", "")
            # will defer printing again, so we can put them into aligned columns:
            tools_rows.append([_tool, path, version])
        else:
            tools_rows.append([_tool, path])

    # Since Docker isn't technically a tool, and only lives in opencos.utils.docker_checks,
    # we would like to still list it here (as "other") to at least show path/version information.
    # This also makes it less surprising if an actual tool has their PATH set with ' [via docker] '
    # (DOCKER_TOOL_NOTE str)
    other_rows = [
        ['--Detected other--', '--Path--']
    ]
    if show_versions:
        other_rows[0].append('--Version--')
    if docker_checks.DOCKER_EXE:
        docker_entry = ['docker', docker_checks.DOCKER_EXE]
        if show_versions:
            docker_entry.append(docker_checks.get_docker_version())
        other_rows.append(docker_entry)

    # Finally, print everything
    pretty_info_tool_rows(tools_rows + other_rows)


def pretty_info_tool_rows(rows: list) -> None:
    '''Pretty print a 2-d list, called by pretty_info_handler_tools'''

    for rownum, row in enumerate(str_helpers.pretty_2dlist_columns(
            rows, return_as_2d_list=True, header_row_centered=False)):
        if rownum == 0 or row[0].startswith('--'):
            util.info("".join(row), color=Colors.bgreen)
        else:
            # get the results in a padded 2D list so we can colorize the name (index 0)
            util.info(f'{row[0]}{Colors.cyan}' \
                      + ''.join(row[1:]), color=Colors.bcyan)


def check_config_tool_requirements( # pylint: disable=too-many-locals,too-many-branches,too-many-statements
        config: dict, tool: str
) -> dict:
    '''Returns a dict of key: bool for various checks that are

    required for this tool in config.tools.TOOLNAME. If tool is not
    present in config, it will return an empty dict.
    '''
    ret = {
        'exe': '',
        'exe_list': [],
        'ALL_CHECKS': False,
    }

    tool_cfg = config.get('tools', {}).get(tool, {})
    if not tool_cfg:
        return ret

    exe_list = eda_config.tool_get_exe_list(tool=tool, config=config)
    ret.update({
        'exe': exe_list[0],
        'exe_list': exe_list
    })

    requires_py_list = tool_cfg.get('requires-py', [])
    for pkg in requires_py_list:
        spec = importlib.util.find_spec(pkg)
        if not spec:
            ret[f'has-required-py__{pkg}'] = False
            util.debug(f"... No, missing python pkg {pkg}: {spec=}")

    requires_env_list = tool_cfg.get('requires-env', [])
    for env in requires_env_list:
        if not os.environ.get(env, ''):
            ret[f'has-required-env__{env}'] = False
            util.debug(f"... No, missing env {env}")

    exe_fpath = None
    for exe in exe_list:
        if exe is not None:
            found_path = exe_helper.get_tool_exe(config=config, tool=tool)
            if not exe_fpath:
                exe_fpath = found_path
                ret['exe'] = found_path
        else:
            found_path = None
        if not found_path:
            # skip the fail if we can run via docker instead:
            if tool_cfg.get('docker-support', None) or \
               tool_cfg.get('requires-docker', None):
                if not docker_checks.docker_ok():
                    ret[f'has-required-exe__{exe}'] = False
                    ret[f'has-required-exe__{exe}__no-docker'] = False
                else:
                    ret[f'has-required-exe__{exe}'] = True
                    ret[f'has-required-exe__{exe}__docker-ok'] = True
                    ret['exe'] = DOCKER_TOOL_NOTE
            else:
                ret[f'has-required-exe__{exe}'] = False

    docker_default_image = tool_cfg.get('docker-default-image', '')
    if docker_checks.docker_ok() and ret['exe'].startswith(DOCKER_TOOL_NOTE) and \
       docker_default_image:
        # We could make sure the docker-default-image exists on this system, and
        # bonus points if we also pre-parsed any --docker-image arg to check that
        # too. However, the goal here isn't to say "you can't run this tool", we
        # simply don't want to force the user to have to pull images they never
        # asked for.
        # Instead, simply show the exe as: " [ via docker ] IMAGE"
        ret['exe'] += docker_default_image


    # has_vsim_helper is handled by exe_helper.get_tool_exe(config, tool)
    # but we'll run it again for debug information:
    if tool_cfg.get('requires-vsim-helper', False):
        # This tool name must be in opencos.utils.vsim_helper.TOOL_PATH[tool].
        # Special case for vsim being used by a lot of tools.
        vsim_helper.init() # only runs checks once internally
        found_path = vsim_helper.TOOL_PATH[tool]
        if not found_path:
            ret['has-required-vsim-helper'] = False

    needs_vscode_extensions = tool_cfg.get('requires-vscode-extension', None)
    if needs_vscode_extensions:
        if not isinstance(needs_vscode_extensions, list):
            util.error(
                f'eda config issue, tool {tool}: requires-vscode-extension must be a list'
            )
        else:
            vscode_helper.init() # only runs checks once internally
            for x in needs_vscode_extensions:
                ret[f'has-required-vscode-extension__{x}'] = (
                    x in vscode_helper.EXTENSIONS
                )

    if ret['exe'] or ret['exe'] is None:
        # We'll run this if we found something for the tools.TOOL.exe,
        # OR, if tools.TOOL.exe is None, means we're likely in a situation where
        # docker-required was also True
        requires_cmd_list = tool_cfg.get('requires-cmd', [])
        for i, cmd in enumerate(requires_cmd_list):
            cmd_list = shlex.split(cmd)
            try:
                proc = subprocess.run(
                    cmd_list, capture_output=True, check=False, input=b'exit\n\n'
                )
                if proc.returncode != 0:
                    util.debug(f"For tool {tool} missing required command {i=}",
                               f"({proc.returncode=}): {cmd_list=}")
                    ret[f'has-required-cmd__{i}'] = False
            except Exception as e:
                ret[f'has-required-cmd__{i}'] = False
                util.debug(f"... No, exception {e} running requires-cmd [{i}]: {cmd_list}")

    needs_docker = tool_cfg.get('requires-docker', None)
    if needs_docker:
        # Confirm that we either have an exe, or that it's set to DOCKER_TOOL_NOTE.
        exe = ret['exe_list'][0]
        ret['has-required-docker'] = docker_checks.docker_ok()


    del ret['ALL_CHECKS']
    ret.update({
        'ALL_CHECKS': all((bool(x) for x in ret.values()))
    })

    return ret
