# Copyright 2010-2023 Gentoo Authors
# Distributed under the terms of the GNU General Public License v2

__all__ = ["env_update"]

import errno
import glob
import stat
import subprocess
import time

import portage
from portage import os, _encodings, _unicode_decode, _unicode_encode
from portage.checksum import prelink_capable
from portage.data import ostype
from portage.exception import ParseError
from portage.localization import _
from portage.process import find_binary
from portage.util import (
    atomic_ofstream,
    ensure_dirs,
    getconfig,
    normalize_path,
    writemsg,
)
from portage.util.listdir import listdir
from portage.dbapi.vartree import vartree
from portage.package.ebuild.config import config


def env_update(
    makelinks=1,
    target_root=None,
    prev_mtimes=None,
    contents=None,
    env=None,
    writemsg_level=None,
    vardbapi=None,
):
    """
    Parse /etc/env.d and use it to generate /etc/profile.env, csh.env,
    ld.so.conf, and prelink.conf. Finally, run ldconfig. When ldconfig is
    called, its -X option will be used in order to avoid potential
    interference with installed soname symlinks that are required for
    correct operation of FEATURES=preserve-libs for downgrade operations.
    It's not necessary for ldconfig to create soname symlinks, since
    portage will use NEEDED.ELF.2 data to automatically create them
    after src_install if they happen to be missing.
    @param makelinks: True if ldconfig should be called, False otherwise
    @param target_root: root that is passed to the ldconfig -r option,
            defaults to portage.settings["ROOT"].
    @type target_root: String (Path)
    """
    if vardbapi is None:
        if isinstance(env, config):
            vardbapi = vartree(settings=env).dbapi
        else:
            if target_root is None:
                eprefix = portage.settings["EPREFIX"]
                target_root = portage.settings["ROOT"]
                target_eroot = portage.settings["EROOT"]
            else:
                eprefix = portage.const.EPREFIX
                target_eroot = os.path.join(target_root, eprefix.lstrip(os.sep))
                target_eroot = target_eroot.rstrip(os.sep) + os.sep
            if hasattr(portage, "db") and target_eroot in portage.db:
                vardbapi = portage.db[target_eroot]["vartree"].dbapi
            else:
                settings = config(
                    config_root=target_root, target_root=target_root, eprefix=eprefix
                )
                target_root = settings["ROOT"]
                if env is None:
                    env = settings
                vardbapi = vartree(settings=settings).dbapi

    # Lock the config memory file to prevent symlink creation
    # in merge_contents from overlapping with env-update.
    vardbapi._fs_lock()
    try:
        return _env_update(
            makelinks, target_root, prev_mtimes, contents, env, writemsg_level
        )
    finally:
        vardbapi._fs_unlock()


def _env_update(makelinks, target_root, prev_mtimes, contents, env, writemsg_level):
    if writemsg_level is None:
        writemsg_level = portage.util.writemsg_level
    if target_root is None:
        target_root = portage.settings["ROOT"]
    if prev_mtimes is None:
        prev_mtimes = portage.mtimedb["ldpath"]
    if env is None:
        settings = portage.settings
    else:
        settings = env

    eprefix = settings.get("EPREFIX", "")
    eprefix_lstrip = eprefix.lstrip(os.sep)
    eroot = (
        normalize_path(os.path.join(target_root, eprefix_lstrip)).rstrip(os.sep)
        + os.sep
    )
    envd_dir = os.path.join(eroot, "etc", "env.d")
    ensure_dirs(envd_dir, mode=0o755)
    fns = listdir(envd_dir, EmptyOnError=1)
    fns.sort()
    templist = []
    for x in fns:
        if len(x) < 3:
            continue
        if not x[0].isdigit() or not x[1].isdigit():
            continue
        if x.startswith(".") or x.endswith("~") or x.endswith(".bak"):
            continue
        templist.append(x)
    fns = templist
    del templist

    space_separated = {"CONFIG_PROTECT", "CONFIG_PROTECT_MASK"}
    colon_separated = {
        "ADA_INCLUDE_PATH",
        "ADA_OBJECTS_PATH",
        "CLASSPATH",
        "INFODIR",
        "INFOPATH",
        "KDEDIRS",
        "LDPATH",
        "MANPATH",
        "PATH",
        "PKG_CONFIG_PATH",
        "PRELINK_PATH",
        "PRELINK_PATH_MASK",
        "PYTHONPATH",
        "ROOTPATH",
    }

    config_list = []

    for x in fns:
        file_path = os.path.join(envd_dir, x)
        try:
            myconfig = getconfig(file_path, expand=False)
        except ParseError as e:
            writemsg(f"!!! '{str(e)}'\n", noiselevel=-1)
            del e
            continue
        if myconfig is None:
            # broken symlink or file removed by a concurrent process
            writemsg(f"!!! File Not Found: '{file_path}'\n", noiselevel=-1)
            continue

        config_list.append(myconfig)
        if "SPACE_SEPARATED" in myconfig:
            space_separated.update(myconfig["SPACE_SEPARATED"].split())
            del myconfig["SPACE_SEPARATED"]
        if "COLON_SEPARATED" in myconfig:
            colon_separated.update(myconfig["COLON_SEPARATED"].split())
            del myconfig["COLON_SEPARATED"]

    env = {}
    specials = {}
    for var in space_separated:
        mylist = []
        for myconfig in config_list:
            if var in myconfig:
                for item in myconfig[var].split():
                    if item and not item in mylist:
                        mylist.append(item)
                del myconfig[var]  # prepare for env.update(myconfig)
        if mylist:
            env[var] = " ".join(mylist)
        specials[var] = mylist

    for var in colon_separated:
        mylist = []
        for myconfig in config_list:
            if var in myconfig:
                for item in myconfig[var].split(":"):
                    if item and not item in mylist:
                        mylist.append(item)
                del myconfig[var]  # prepare for env.update(myconfig)
        if mylist:
            env[var] = ":".join(mylist)
        specials[var] = mylist

    for myconfig in config_list:
        """Cumulative variables have already been deleted from myconfig so that
        they won't be overwritten by this dict.update call."""
        env.update(myconfig)

    ldsoconf_path = os.path.join(eroot, "etc", "ld.so.conf")
    try:
        myld = open(
            _unicode_encode(ldsoconf_path, encoding=_encodings["fs"], errors="strict"),
            encoding=_encodings["content"],
            errors="replace",
        )
        myldlines = myld.readlines()
        myld.close()
        oldld = []
        for x in myldlines:
            # each line has at least one char (a newline)
            if x[:1] == "#":
                continue
            oldld.append(x[:-1])
    except OSError as e:
        if e.errno != errno.ENOENT:
            raise
        oldld = None

    newld = specials["LDPATH"]
    if oldld != newld:
        # ld.so.conf needs updating and ldconfig needs to be run
        myfd = atomic_ofstream(ldsoconf_path)
        myfd.write("# ld.so.conf autogenerated by env-update; make all changes to\n")
        myfd.write(f"# contents of {eprefix}/etc/env.d directory.\n")
        for x in specials["LDPATH"]:
            myfd.write(x + "\n")
        myfd.close()

        if eprefix:
            # ldconfig needs ld.so.conf paths to be prefixed, but the bfd linker
            # needs them unprefixed, so write an alternative ld.so.conf file for
            # the latter. Other linkers do not use these files. See ldelf.c in
            # binutils for precise bfd behavior, as well as bug #892549.
            ldsoconf_path = os.path.join(eroot, "usr", "etc", "ld.so.conf")
            ensure_dirs(os.path.dirname(ldsoconf_path), mode=0o755)
            myfd = atomic_ofstream(ldsoconf_path)
            myfd.write(
                "# ld.so.conf autogenerated by env-update; make all changes to\n"
                f"# contents of {eprefix}/etc/env.d directory.\n"
                "# This file is only used by the bfd linker. The paths are not\n"
                "# prefixed as this is automatically added by the linker.\n"
            )
            for x in specials["LDPATH"]:
                if x.startswith(eprefix + os.path.sep):
                    myfd.write(x[len(eprefix) :] + "\n")
            myfd.close()

    potential_lib_dirs = set()
    for lib_dir_glob in ("usr/lib*", "lib*"):
        x = os.path.join(eroot, lib_dir_glob)
        for y in glob.glob(
            _unicode_encode(x, encoding=_encodings["fs"], errors="strict")
        ):
            try:
                y = _unicode_decode(y, encoding=_encodings["fs"], errors="strict")
            except UnicodeDecodeError:
                continue
            if os.path.basename(y) != "libexec":
                potential_lib_dirs.add(y[len(eroot) :])

    # Update prelink.conf if we are prelink-enabled
    if prelink_capable:
        prelink_d = os.path.join(eroot, "etc", "prelink.conf.d")
        ensure_dirs(prelink_d)
        newprelink = atomic_ofstream(os.path.join(prelink_d, "portage.conf"))
        newprelink.write(
            "# prelink.conf autogenerated by env-update; make all changes to\n"
        )
        newprelink.write(f"# contents of {eprefix}/etc/env.d directory\n")

        for x in sorted(potential_lib_dirs) + ["bin", "sbin"]:
            newprelink.write(f"-l /{x}\n")
        prelink_paths = set()
        prelink_paths |= set(specials.get("LDPATH", []))
        prelink_paths |= set(specials.get("PATH", []))
        prelink_paths |= set(specials.get("PRELINK_PATH", []))
        prelink_path_mask = specials.get("PRELINK_PATH_MASK", [])
        for x in prelink_paths:
            if not x:
                continue
            if x[-1:] != "/":
                x += "/"
            plmasked = 0
            for y in prelink_path_mask:
                if not y:
                    continue
                if y[-1] != "/":
                    y += "/"
                if y == x[0 : len(y)]:
                    plmasked = 1
                    break
            if not plmasked:
                newprelink.write(f"-h {x}\n")
        for x in prelink_path_mask:
            newprelink.write(f"-b {x}\n")
        newprelink.close()

        # Migration code path.  If /etc/prelink.conf was generated by us, then
        # point it to the new stuff until the prelink package re-installs.
        prelink_conf = os.path.join(eroot, "etc", "prelink.conf")
        try:
            with open(
                _unicode_encode(
                    prelink_conf, encoding=_encodings["fs"], errors="strict"
                ),
                "rb",
            ) as f:
                if (
                    f.readline()
                    == b"# prelink.conf autogenerated by env-update; make all changes to\n"
                ):
                    f = atomic_ofstream(prelink_conf)
                    f.write(f"-c {eprefix}/etc/prelink.conf.d/*.conf\n")
                    f.close()
        except OSError as e:
            if e.errno != errno.ENOENT:
                raise

    current_time = int(time.time())
    mtime_changed = False

    lib_dirs = set()
    for lib_dir in set(specials["LDPATH"]) | potential_lib_dirs:
        x = os.path.join(eroot, lib_dir.lstrip(os.sep))
        try:
            newldpathtime = os.stat(x)[stat.ST_MTIME]
            lib_dirs.add(normalize_path(x))
        except OSError as oe:
            if oe.errno == errno.ENOENT:
                try:
                    del prev_mtimes[x]
                except KeyError:
                    pass
                # ignore this path because it doesn't exist
                continue
            raise
        if newldpathtime == current_time:
            # Reset mtime to avoid the potential ambiguity of times that
            # differ by less than 1 second.
            newldpathtime -= 1
            os.utime(x, (newldpathtime, newldpathtime))
            prev_mtimes[x] = newldpathtime
            mtime_changed = True
        elif x in prev_mtimes:
            if prev_mtimes[x] == newldpathtime:
                pass
            else:
                prev_mtimes[x] = newldpathtime
                mtime_changed = True
        else:
            prev_mtimes[x] = newldpathtime
            mtime_changed = True

    if makelinks and not mtime_changed and contents is not None:
        libdir_contents_changed = False
        for mypath, mydata in contents.items():
            if mydata[0] not in ("obj", "sym"):
                continue
            head, tail = os.path.split(mypath)
            if head in lib_dirs:
                libdir_contents_changed = True
                break
        if not libdir_contents_changed:
            makelinks = False

    if (
        "CHOST" in settings
        and "CBUILD" in settings
        and settings["CHOST"] != settings["CBUILD"]
    ):
        ldconfig = find_binary(f"{settings['CHOST']}-ldconfig")
    else:
        ldconfig = os.path.join(eroot, "sbin", "ldconfig")

    if ldconfig is None:
        pass
    elif not (os.access(ldconfig, os.X_OK) and os.path.isfile(ldconfig)):
        ldconfig = None

    # Only run ldconfig as needed
    if makelinks and ldconfig:
        # ldconfig has very different behaviour between FreeBSD and Linux
        if ostype == "Linux" or ostype.lower().endswith("gnu"):
            # We can't update links if we haven't cleaned other versions first, as
            # an older package installed ON TOP of a newer version will cause ldconfig
            # to overwrite the symlinks we just made. -X means no links. After 'clean'
            # we can safely create links.
            writemsg_level(_(f">>> Regenerating {eroot}etc/ld.so.cache...\n"))
            ret = subprocess.run(
                [ldconfig, "-X", "-r", target_root], cwd="/"
            ).returncode
        elif ostype in ("FreeBSD", "DragonFly"):
            writemsg_level(
                _(f">>> Regenerating {target_root}var/run/ld-elf.so.hints...\n")
            )
            ret = subprocess.run(
                [
                    ldconfig,
                    "-elf",
                    "-i",
                    "-f",
                    f"{target_root}var/run/ld-elf.so.hints",
                    f"{target_root}etc/ld.so.conf",
                ],
                cwd="/",
            ).returncode

        if ret > 0:
            writemsg(f"!!! ldconfig failed with exit status {ret}\n", noiselevel=-1)
        if ret < 0:
            writemsg(f"!!! ldconfig was killed with signal {-ret}\n", noiselevel=-1)

    del specials["LDPATH"]

    notice = "# THIS FILE IS AUTOMATICALLY GENERATED BY env-update.\n"
    notice += "# DO NOT EDIT THIS FILE."
    penvnotice = notice + " CHANGES TO STARTUP PROFILES\n"
    cenvnotice = penvnotice[:]
    penvnotice += f"# GO INTO {eprefix}/etc/profile NOT {eprefix}/etc/profile.env\n\n"
    cenvnotice += f"# GO INTO {eprefix}/etc/csh.cshrc NOT {eprefix}/etc/csh.env\n\n"

    # create /etc/profile.env for bash support
    profile_env_path = os.path.join(eroot, "etc", "profile.env")
    with atomic_ofstream(profile_env_path) as outfile:
        outfile.write(penvnotice)

        env_keys = [x for x in env if x != "LDPATH"]
        env_keys.sort()
        for k in env_keys:
            v = env[k]
            if v.startswith("$") and not v.startswith("${"):
                outfile.write(f"export {k}=$'{v[1:]}'\n")
            else:
                outfile.write(f"export {k}='{v}'\n")

    # Create the systemd user environment configuration file
    # /etc/environment.d/10-gentoo-env.conf with the
    # environment configuration from /etc/env.d.
    systemd_environment_dir = os.path.join(eroot, "etc", "environment.d")
    os.makedirs(systemd_environment_dir, exist_ok=True)

    systemd_gentoo_env_path = os.path.join(
        systemd_environment_dir, "10-gentoo-env.conf"
    )
    with atomic_ofstream(systemd_gentoo_env_path) as systemd_gentoo_env:
        senvnotice = notice + "\n\n"
        systemd_gentoo_env.write(senvnotice)

        for env_key in env_keys:
            env_key_value = env[env_key]

            # Skip variables with the empty string
            # as value. Those sometimes appear in
            # profile.env (e.g. "export GCC_SPECS=''"),
            # but are invalid in systemd's syntax.
            if not env_key_value:
                continue

            # Transform into systemd environment.d
            # conf syntax, basically shell variable
            # assignment (without "export ").
            line = f"{env_key}={env_key_value}\n"

            systemd_gentoo_env.write(line)

    # create /etc/csh.env for (t)csh support
    outfile = atomic_ofstream(os.path.join(eroot, "etc", "csh.env"))
    outfile.write(cenvnotice)
    for x in env_keys:
        outfile.write(f"setenv {x} '{env[x]}'\n")
    outfile.close()
