"""Tools to help interface with foreign shells, such as Bash."""

import collections.abc as cabc
import functools
import locale
import os
import re
import shlex
import subprocess
import sys
import tempfile
import warnings

from xonsh.built_ins import XSH
from xonsh.lib.lazyasd import lazyobject
from xonsh.platform import ON_CYGWIN, ON_MSYS, ON_WINDOWS
from xonsh.tools import ensure_string, to_bool

COMMAND = """{seterrprevcmd}
{prevcmd}
echo __XONSH_ENV_BEG__
{envcmd}
echo __XONSH_ENV_END__
echo __XONSH_ALIAS_BEG__
{aliascmd}
echo __XONSH_ALIAS_END__
echo __XONSH_FUNCS_BEG__
{funcscmd}
echo __XONSH_FUNCS_END__
{postcmd}
{seterrpostcmd}"""

DEFAULT_BASH_FUNCSCMD = """# get function names from declare
declstr=$(echo $(declare -F))
read -r -a decls <<< $declstr
for((n=0;n<${#decls[@]};n++)); do
  if (( $(($n % 3 )) == 2 )); then
    echo -n "${decls[$n]} "
  fi
done
echo"""

DEFAULT_ZSH_FUNCSCMD = """# get function names
for name in ${(ok)functions}; do
  echo -n "$name "
done
echo"""


# mapping of shell name aliases to keys in other lookup dictionaries.
@lazyobject
def CANON_SHELL_NAMES():
    return {
        "bash": "bash",
        "/bin/bash": "bash",
        "zsh": "zsh",
        "/bin/zsh": "zsh",
        "/usr/bin/zsh": "zsh",
        # POSIX ``sh`` — issue #5894. ``/bin/sh`` may be bash, dash, or
        # another POSIX shell depending on distro, so the defaults below
        # stick to POSIX-only constructs (``.`` instead of ``source``,
        # no function listing, ``alias`` without ``-L``). ``dash``,
        # ``ash``, and the ksh family (``ksh``, ``mksh``, ``pdksh``)
        # are POSIX-compatible enough to share the same defaults, so we
        # alias them all to ``sh`` directly. Their shell-specific
        # function-listing builtins (``typeset +f`` and friends) are
        # not exposed — env and aliases still come through.
        "sh": "sh",
        "/bin/sh": "sh",
        "/usr/bin/sh": "sh",
        "dash": "sh",
        "/bin/dash": "sh",
        "/usr/bin/dash": "sh",
        "ash": "sh",
        "/bin/ash": "sh",
        "/usr/bin/ash": "sh",
        "ksh": "sh",
        "/bin/ksh": "sh",
        "/usr/bin/ksh": "sh",
        "mksh": "sh",
        "/bin/mksh": "sh",
        "/usr/bin/mksh": "sh",
        "pdksh": "sh",
        "/bin/pdksh": "sh",
        "/usr/bin/pdksh": "sh",
        "cmd": "cmd",
        "cmd.exe": "cmd",
    }


@lazyobject
def DEFAULT_ENVCMDS():
    return {
        # env -0 uses null bytes as separators, which correctly handles
        # multi-line values (e.g. BASH_FUNC_* exported functions).
        # Falls back to plain env if -0 is not supported.
        "bash": "env -0 2>/dev/null || env",
        "zsh": "env -0 2>/dev/null || env",
        "sh": "env -0 2>/dev/null || env",
        "cmd": "set",
    }


@lazyobject
def DEFAULT_ALIASCMDS():
    # ``alias -L`` is zsh-only; bare ``alias`` is POSIX.
    return {"bash": "alias", "zsh": "alias -L", "sh": "alias", "cmd": ""}


@lazyobject
def DEFAULT_FUNCSCMDS():
    # POSIX sh has no portable way to enumerate functions; bash and zsh
    # variants both rely on shell-specific builtins. Leave empty for sh.
    return {
        "bash": DEFAULT_BASH_FUNCSCMD,
        "zsh": DEFAULT_ZSH_FUNCSCMD,
        "sh": "",
        "cmd": "",
    }


@lazyobject
def DEFAULT_SOURCERS():
    # ``.`` is the POSIX sourcing builtin; ``source`` is a bash/zsh
    # extension. ``/bin/sh`` on dash-based distros (Debian, Ubuntu,
    # Alpine) does not support ``source``.
    return {"bash": "source", "zsh": "source", "sh": ".", "cmd": "call"}


@lazyobject
def DEFAULT_TMPFILE_EXT():
    return {"bash": ".sh", "zsh": ".zsh", "sh": ".sh", "cmd": ".bat"}


@lazyobject
def DEFAULT_RUNCMD():
    return {"bash": "-c", "zsh": "-c", "sh": "-c", "cmd": "/C"}


@lazyobject
def DEFAULT_SETERRPREVCMD():
    return {"bash": "set -e", "zsh": "set -e", "sh": "set -e", "cmd": "@echo off"}


@lazyobject
def DEFAULT_SETERRPOSTCMD():
    return {"bash": "", "zsh": "", "sh": "", "cmd": "if errorlevel 1 exit 1"}


_FOREIGN_OUTPUT_FIRST_MARKER = "__XONSH_ENV_BEG__"


def _emit_foreign_script_output(stdout_str, stderr_str):
    """Write the sourced script's own stdout/stderr to xonsh's terminal.

    The captured stdout from the foreign shell contains both the script's
    own writes and the xonsh marker output (``__XONSH_ENV_BEG__`` and
    onwards). Strip everything from the first marker so only what the
    script itself printed is surfaced. Stderr is forwarded as-is.
    """
    pre_marker = stdout_str or ""
    if pre_marker:
        idx = pre_marker.find(_FOREIGN_OUTPUT_FIRST_MARKER)
        if idx != -1:
            pre_marker = pre_marker[:idx]
        if pre_marker:
            sys.stdout.write(pre_marker)
            sys.stdout.flush()
    if stderr_str:
        sys.stderr.write(stderr_str)
        sys.stderr.flush()


@functools.lru_cache
def foreign_shell_data(
    shell,
    interactive=True,
    login=False,
    envcmd=None,
    aliascmd=None,
    extra_args=(),
    currenv=None,
    safe=True,
    prevcmd="",
    postcmd="",
    funcscmd=None,
    sourcer=None,
    use_tmpfile=False,
    tmpfile_ext=None,
    runcmd=None,
    seterrprevcmd=None,
    seterrpostcmd=None,
    show=False,
    show_output=False,
    dryrun=False,
    files=(),
):
    """Extracts data from a foreign (non-xonsh) shells. Currently this gets
    the environment, aliases, and functions but may be extended in the future.

    Parameters
    ----------
    shell : str
        The name of the shell, such as 'bash' or '/bin/sh'.
    interactive : bool, optional
        Whether the shell should be run in interactive mode.
    login : bool, optional
        Whether the shell should be a login shell.
    envcmd : str or None, optional
        The command to generate environment output with.
    aliascmd : str or None, optional
        The command to generate alias output with.
    extra_args : tuple of str, optional
        Additional command line options to pass into the shell.
    currenv : tuple of items or None, optional
        Manual override for the current environment.
    safe : bool, optional
        Flag for whether or not to safely handle exceptions and other errors.
    prevcmd : str, optional
        A command to run in the shell before anything else, useful for
        sourcing and other commands that may require environment recovery.
    postcmd : str, optional
        A command to run after everything else, useful for cleaning up any
        damage that the prevcmd may have caused.
    funcscmd : str or None, optional
        This is a command or script that can be used to determine the names
        and locations of any functions that are native to the foreign shell.
        This command should print *only* a JSON object that maps
        function names to the filenames where the functions are defined.
        If this is None, then a default script will attempted to be looked
        up based on the shell name. Callable wrappers for these functions
        will be returned in the aliases dictionary.
    sourcer : str or None, optional
        How to source a foreign shell file for purposes of calling functions
        in that shell. If this is None, a default value will attempt to be
        looked up based on the shell name.
    use_tmpfile : bool, optional
        This specifies if the commands are written to a tmp file or just
        parsed directly to the shell
    tmpfile_ext : str or None, optional
        If tmpfile is True this sets specifies the extension used.
    runcmd : str or None, optional
        Command line switches to use when running the script, such as
        -c for Bash and /C for cmd.exe.
    seterrprevcmd : str or None, optional
        Command that enables exit-on-error for the shell that is run at the
        start of the script. For example, this is "set -e" in Bash. To disable
        exit-on-error behavior, simply pass in an empty string.
    seterrpostcmd : str or None, optional
        Command that enables exit-on-error for the shell that is run at the end
        of the script. For example, this is "if errorlevel 1 exit 1" in
        cmd.exe. To disable exit-on-error behavior, simply pass in an
        empty string.
    show : bool, optional
        Whether or not to display the script that will be run.
    show_output : bool, optional
        Whether to forward the sourced script's own stdout/stderr to xonsh's
        terminal. When False (the default) the script's writes are silently
        discarded — only the xonsh-injected env/alias/func dumps are parsed
        out and applied. Useful when sourcing scripts that print banners or
        diagnostic messages, or for debugging a failing source.
    dryrun : bool, optional
        Whether or not to actually run and process the command.
    files : tuple of str, optional
        Paths to source.

    Returns
    -------
    env : dict
        Dictionary of shell's environment. (None if the subproc command fails)
    aliases : dict
        Dictionary of shell's aliases, this includes foreign function
        wrappers.(None if the subproc command fails)
    """
    cmd = [shell]
    cmd.extend(extra_args)  # needs to come here for GNU long options
    if interactive:
        cmd.append("-i")
    if login:
        cmd.append("-l")
    shkey = CANON_SHELL_NAMES.get(shell) or CANON_SHELL_NAMES.get(
        os.path.basename(shell)
    )
    if shkey is None:
        # Issue #5894: unknown shell name used to raise KeyError that
        # bubbled all the way up through ProcProxyThread as an
        # unhandled traceback. Under the default ``safe=True`` we
        # surface a clean message and behave like every other failure
        # path (return None, None → caller prints the friendly source-
        # failed error). Callers that opted out of safe handling still
        # see the exception.
        supported = ", ".join(sorted(set(CANON_SHELL_NAMES.values())))
        if not safe:
            raise KeyError(f"Unknown foreign shell {shell!r}. Supported: {supported}")
        sys.stderr.write(
            f"xonsh: error: unknown foreign shell {shell!r}. Supported: {supported}\n"
        )
        sys.stderr.flush()
        return None, None
    envcmd = DEFAULT_ENVCMDS.get(shkey, "env") if envcmd is None else envcmd
    aliascmd = DEFAULT_ALIASCMDS.get(shkey, "alias") if aliascmd is None else aliascmd
    funcscmd = DEFAULT_FUNCSCMDS.get(shkey, "echo {}") if funcscmd is None else funcscmd
    tmpfile_ext = (
        DEFAULT_TMPFILE_EXT.get(shkey, "sh") if tmpfile_ext is None else tmpfile_ext
    )
    runcmd = DEFAULT_RUNCMD.get(shkey, "-c") if runcmd is None else runcmd
    seterrprevcmd = (
        DEFAULT_SETERRPREVCMD.get(shkey, "") if seterrprevcmd is None else seterrprevcmd
    )
    seterrpostcmd = (
        DEFAULT_SETERRPOSTCMD.get(shkey, "") if seterrpostcmd is None else seterrpostcmd
    )
    command = COMMAND.format(
        envcmd=envcmd,
        aliascmd=aliascmd,
        prevcmd=prevcmd,
        postcmd=postcmd,
        funcscmd=funcscmd,
        seterrprevcmd=seterrprevcmd,
        seterrpostcmd=seterrpostcmd,
    ).strip()
    if show:
        print(command)
    if dryrun:
        return None, None
    cmd.append(runcmd)
    if not use_tmpfile:
        cmd.append(command)
    else:
        tmpfile = tempfile.NamedTemporaryFile(suffix=tmpfile_ext, delete=False)
        tmpfile.write(command.encode("utf8"))
        tmpfile.close()
        cmd.append(tmpfile.name)
    if currenv is None and XSH.env:
        currenv = XSH.env.detype()
    elif currenv is not None:
        currenv = dict(currenv)
    try:
        proc = subprocess.run(
            cmd,
            capture_output=True,
            check=True,
            env=currenv,
            # start new session to avoid hangs
            # (doesn't work on Cygwin though)
            start_new_session=((not ON_CYGWIN) and (not ON_MSYS)),
            encoding=locale.getpreferredencoding(False),
        )
        s = proc.stdout
        captured_stderr = proc.stderr or ""
    except subprocess.CalledProcessError as e:
        # Always surface the foreign shell's stderr on failure — that's
        # where users most need it (and ``--show-output`` is opt-in for
        # the success case where the script's stdout would also be
        # forwarded). Without this, callers like ``source-zsh`` would
        # print a generic "failed to source" message with no hint of why.
        stderr_str = e.stderr or ""
        if stderr_str:
            sys.stderr.write(stderr_str)
            sys.stderr.flush()
        if show_output and e.stdout:
            _emit_foreign_script_output(e.stdout, "")
        if not safe:
            raise
        return None, None
    except FileNotFoundError as e:
        # The foreign shell binary itself wasn't found. Tell the user
        # which binary was missing — otherwise the caller's "failed to
        # source" message looks identical to a script-level error.
        sys.stderr.write(f"xonsh: error: foreign shell not found: {shell!r} ({e})\n")
        sys.stderr.flush()
        if not safe:
            raise
        return None, None
    finally:
        if use_tmpfile:
            os.remove(tmpfile.name)
    if show_output:
        _emit_foreign_script_output(s, captured_stderr)
    env = parse_env(s)
    aliases = parse_aliases(
        s,
        shell=shell,
        sourcer=sourcer,
        files=files,
        extra_args=extra_args,
    )
    funcs = parse_funcs(
        s,
        shell=shell,
        sourcer=sourcer,
        files=files,
        extra_args=extra_args,
    )
    aliases.update(funcs)
    return env, aliases


@lazyobject
def ENV_RE():
    return re.compile("__XONSH_ENV_BEG__\n(.*)__XONSH_ENV_END__", flags=re.DOTALL)


@lazyobject
def _ENV_LINE_KEY_RE():
    """Matches the start of a KEY=VALUE line in env output.

    A valid env var name starts with a letter or underscore, followed by
    letters, digits, underscores, or percent signs (for BASH_FUNC_xxx%%).
    """
    return re.compile(r"^([A-Za-z_][A-Za-z0-9_%]*)=(.*)", re.DOTALL)


def parse_env(s):
    """Parses the environment portion of string into a dict."""
    m = ENV_RE.search(s)
    if m is None:
        return {}
    g1 = m.group(1)
    g1 = g1[:-1] if g1.endswith("\n") else g1
    if "\0" in g1:
        # Null-separated output from env -0.
        # Correctly handles multi-line values (e.g. exported bash functions).
        env = {}
        for entry in g1.split("\0"):
            if "=" in entry:
                key, value = entry.split("=", 1)
                env[key] = value
        return env
    # Fallback for systems where env -0 is not available.
    # Detect new KEY=VALUE pairs by matching lines that start with a
    # valid variable name followed by '='. Lines that don't match are
    # treated as continuation of the previous value.
    env = {}
    key = None
    value_lines: list[str] = []
    for line in g1.split("\n"):
        m = _ENV_LINE_KEY_RE.match(line)
        if m:
            if key is not None:
                env[key] = "\n".join(value_lines)
            key = m.group(1)
            value_lines = [m.group(2)]
        elif key is not None:
            value_lines.append(line)
    if key is not None:
        env[key] = "\n".join(value_lines)
    return env


@lazyobject
def ALIAS_RE():
    return re.compile("__XONSH_ALIAS_BEG__\n(.*)__XONSH_ALIAS_END__", flags=re.DOTALL)


@lazyobject
def FS_EXEC_ALIAS_RE():
    return re.compile(r";|`|\$\(")


def parse_aliases(s, shell, sourcer=None, files=(), extra_args=()):
    """Parses the aliases portion of string into a dict."""
    m = ALIAS_RE.search(s)
    if m is None:
        return {}
    g1 = m.group(1)
    g1 = g1.replace("\\\n", " ")
    items = [
        line.split("=", 1)
        for line in g1.splitlines()
        if line.startswith("alias ") and "=" in line
    ]
    aliases = {}
    for key, value in items:
        try:
            key = key[6:]  # lstrip 'alias '
            # undo bash's weird quoting of single quotes (sh_single_quote)
            value = value.replace("'\\''", "'")
            # strip one single quote at the start and end of value
            if len(value) >= 2 and value[0] == "'" and value[-1] == "'":
                value = value[1:-1]
            # now compute actual alias
            if FS_EXEC_ALIAS_RE.search(value) is None:
                # simple list of args alias
                value = shlex.split(value)
            else:
                # alias is more complex, use ExecAlias, but via shell
                value = ForeignShellExecAlias(
                    src=value,
                    shell=shell,
                    sourcer=sourcer,
                    files=files,
                    extra_args=extra_args,
                )
        except ValueError as exc:
            warnings.warn(
                f'could not parse alias "{key}": {exc!r}',
                RuntimeWarning,
                stacklevel=2,
            )
            continue
        aliases[key] = value
    return aliases


@lazyobject
def FUNCS_RE():
    return re.compile("__XONSH_FUNCS_BEG__\n(.+)\n__XONSH_FUNCS_END__", flags=re.DOTALL)


def parse_funcs(s, shell, sourcer=None, files=(), extra_args=()):
    """Parses the funcs portion of a string into a dict of callable foreign
    function wrappers.
    """
    m = FUNCS_RE.search(s)
    if m is None:
        return {}
    g1 = m.group(1)
    if ON_WINDOWS:
        g1 = g1.replace(os.sep, os.altsep)
    funcnames = g1.split()
    funcs = {}
    for funcname in funcnames:
        if funcname.startswith("_"):
            continue  # skip private functions and invalid files
        wrapper = ForeignShellFunctionAlias(
            funcname=funcname,
            shell=shell,
            sourcer=sourcer,
            files=files,
            extra_args=extra_args,
        )
        funcs[funcname] = wrapper
    return funcs


class ForeignShellBaseAlias:
    """This class is responsible for calling foreign shell functions as if
    they were aliases. This does not currently support taking stdin.
    """

    INPUT = "echo ForeignShellBaseAlias {shell} {args}\n"

    def __init__(self, shell, sourcer=None, files=(), extra_args=()):
        """
        Parameters
        ----------
        shell : str
            Name or path to shell
        sourcer : str or None, optional
            Command to source foreign files with.
        files : tuple of str, optional
            Paths to source.
        extra_args : tuple of str, optional
            Additional command line options to pass into the shell.
        """
        sourcer = DEFAULT_SOURCERS.get(shell, "source") if sourcer is None else sourcer
        self.shell = shell
        self.sourcer = sourcer
        self.files = files
        self.extra_args = extra_args

    def _input_kwargs(self):
        return {
            "shell": self.shell,
            "extra_args": self.extra_args,
        }

    def __eq__(self, other):
        if not hasattr(other, "_input_kwargs") or not callable(other._input_kwargs):
            return NotImplemented
        return self._input_kwargs() == other._input_kwargs()

    def __call__(
        self, args, stdin=None, stdout=None, stderr=None, spec=None, stack=None
    ):
        args, streaming = self._is_streaming(args)
        input = self.INPUT.format(
            args=" ".join(shlex.quote(a) for a in args), **self._input_kwargs()
        )
        if len(self.files) > 0:
            input = (
                "".join([f"{self.sourcer} {shlex.quote(f)}\n" for f in self.files])
                + input
            )
        cmd = [self.shell] + list(self.extra_args) + ["-ic", input]
        env = XSH.env
        denv = env.detype()
        if streaming:
            # Pass the alias's stdout/stderr through to the foreign shell so
            # subprocess captures (e.g. ``$(foo)``) work. Without this, the
            # foreign shell inherits xonsh's terminal fds and writes around
            # the pipe xonsh set up for capture (issue #5043).
            subprocess.check_call(cmd, env=denv, stdout=stdout, stderr=stderr)
            out = None
        else:
            out = subprocess.check_output(cmd, env=denv, stderr=subprocess.STDOUT)
            out = out.decode(
                encoding=env.get("XONSH_ENCODING"),
                errors=env.get("XONSH_ENCODING_ERRORS"),
            )
            out = out.replace("\r\n", "\n")
        return out

    def __repr__(self):
        return (
            self.__class__.__name__
            + "("
            + ", ".join([f"{k}={v!r}" for k, v in sorted(self._input_kwargs().items())])
            + ")"
        )

    @staticmethod
    def _is_streaming(args):
        """Test and modify args if ``--xonsh-nostream`` is present."""
        if "--xonsh-nostream" not in args:
            return args, True
        warnings.warn(
            "--xonsh-nostream is deprecated and will be removed in a future "
            "release. Streaming mode (the default) now captures correctly "
            "via $(...) and !(...) — drop the flag.",
            DeprecationWarning,
            stacklevel=3,
        )
        args = list(args)
        args.remove("--xonsh-nostream")
        return args, False


class ForeignShellFunctionAlias(ForeignShellBaseAlias):
    """This class is responsible for calling foreign shell functions as if
    they were aliases. This does not currently support taking stdin.
    """

    INPUT = "{funcname} {args}\n"

    def __init__(self, funcname, shell, sourcer=None, files=(), extra_args=()):
        """
        Parameters
        ----------
        funcname : str
            function name
        shell : str
            Name or path to shell
        sourcer : str or None, optional
            Command to source foreign files with.
        files : tuple of str, optional
            Paths to source.
        extra_args : tuple of str, optional
            Additional command line options to pass into the shell.
        """
        super().__init__(
            shell=shell, sourcer=sourcer, files=files, extra_args=extra_args
        )
        self.funcname = funcname

    def _input_kwargs(self):
        inp = super()._input_kwargs()
        inp["funcname"] = self.funcname
        return inp


class ForeignShellExecAlias(ForeignShellBaseAlias):
    """Provides a callable alias for source code in a foreign shell."""

    INPUT = "{src} {args}\n"

    def __init__(
        self,
        src,
        shell,
        sourcer=None,
        files=(),
        extra_args=(),
    ):
        """
        Parameters
        ----------
        src : str
            Source code in the shell language
        shell : str
            Name or path to shell
        sourcer : str or None, optional
            Command to source foreign files with.
        files : tuple of str, optional
            Paths to source.
        extra_args : tuple of str, optional
            Additional command line options to pass into the shell.
        """
        super().__init__(
            shell=shell, sourcer=sourcer, files=files, extra_args=extra_args
        )
        self.src = src.strip()

    def _input_kwargs(self):
        inp = super()._input_kwargs()
        inp["src"] = self.src
        return inp


@lazyobject
def VALID_SHELL_PARAMS():
    return frozenset(
        [
            "shell",
            "interactive",
            "login",
            "envcmd",
            "aliascmd",
            "extra_args",
            "currenv",
            "safe",
            "prevcmd",
            "postcmd",
            "funcscmd",
            "sourcer",
        ]
    )


def ensure_shell(shell):
    """Ensures that a mapping follows the shell specification."""
    if not isinstance(shell, cabc.MutableMapping):
        shell = dict(shell)
    shell_keys = set(shell.keys())
    if not (shell_keys <= VALID_SHELL_PARAMS):
        raise KeyError(f"unknown shell keys: {shell_keys - VALID_SHELL_PARAMS}")
    shell["shell"] = ensure_string(shell["shell"]).lower()
    if "interactive" in shell_keys:
        shell["interactive"] = to_bool(shell["interactive"])
    if "login" in shell_keys:
        shell["login"] = to_bool(shell["login"])
    if "envcmd" in shell_keys:
        shell["envcmd"] = (
            None if shell["envcmd"] is None else ensure_string(shell["envcmd"])
        )
    if "aliascmd" in shell_keys:
        shell["aliascmd"] = (
            None if shell["aliascmd"] is None else ensure_string(shell["aliascmd"])
        )
    if "extra_args" in shell_keys and not isinstance(shell["extra_args"], tuple):
        shell["extra_args"] = tuple(map(ensure_string, shell["extra_args"]))
    if "currenv" in shell_keys and not isinstance(shell["currenv"], tuple):
        ce = shell["currenv"]
        if isinstance(ce, cabc.Mapping):
            ce = tuple((ensure_string(k), v) for k, v in ce.items())
        elif isinstance(ce, cabc.Sequence):
            ce = tuple((ensure_string(k), v) for k, v in ce)
        else:
            raise RuntimeError("unrecognized type for currenv")
        shell["currenv"] = ce
    if "safe" in shell_keys:
        shell["safe"] = to_bool(shell["safe"])
    if "prevcmd" in shell_keys:
        shell["prevcmd"] = ensure_string(shell["prevcmd"])
    if "postcmd" in shell_keys:
        shell["postcmd"] = ensure_string(shell["postcmd"])
    if "funcscmd" in shell_keys:
        shell["funcscmd"] = (
            None if shell["funcscmd"] is None else ensure_string(shell["funcscmd"])
        )
    if "sourcer" in shell_keys:
        shell["sourcer"] = (
            None if shell["sourcer"] is None else ensure_string(shell["sourcer"])
        )
    if "seterrprevcmd" in shell_keys:
        shell["seterrprevcmd"] = (
            None
            if shell["seterrprevcmd"] is None
            else ensure_string(shell["seterrprevcmd"])
        )
    if "seterrpostcmd" in shell_keys:
        shell["seterrpostcmd"] = (
            None
            if shell["seterrpostcmd"] is None
            else ensure_string(shell["seterrpostcmd"])
        )
    return shell


def load_foreign_envs(shells):
    """Loads environments from foreign shells.

    Parameters
    ----------
    shells : sequence of dicts
        An iterable of dicts that can be passed into foreign_shell_data() as
        keyword arguments.

    Returns
    -------
    env : dict
        A dictionary of the merged environments.
    """
    env = {}
    for shell in shells:
        shell = ensure_shell(shell)
        shenv, _ = foreign_shell_data(**shell)
        if shenv:
            env.update(shenv)
    return env


def load_foreign_aliases(shells):
    """Loads aliases from foreign shells.

    Parameters
    ----------
    shells : sequence of dicts
        An iterable of dicts that can be passed into foreign_shell_data() as
        keyword arguments.

    Returns
    -------
    aliases : dict
        A dictionary of the merged aliases.
    """
    aliases = {}
    xonsh_aliases = XSH.aliases
    for shell in shells:
        shell = ensure_shell(shell)
        _, shaliases = foreign_shell_data(**shell)
        if shaliases is None:
            shaliases = {}
        if not XSH.env.get("FOREIGN_ALIASES_OVERRIDE"):
            for alias in set(shaliases) & set(xonsh_aliases):
                del shaliases[alias]
                if XSH.env.get("XONSH_DEBUG", 0) >= 1:
                    print(
                        f"aliases: ignoring alias {alias!r} of shell {shell['shell']!r} "
                        "which tries to override xonsh alias.",
                        file=sys.stderr,
                    )
        aliases.update(shaliases)
    return aliases
