#!/usr/bin/env python3
# Copyright (c) 2013 The Chromium OS Authors. All rights reserved.
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.

"""Wrapper for building the Chromium OS platform.

Takes care of running GN/ninja/etc... with all the right values.
"""

import collections
import glob
import json
import os

import six

from chromite.lib import commandline
from chromite.lib import cros_build_lib
from chromite.lib import osutils
from chromite.lib import portage_util

import common_utils
import ebuild_function

# USE flags used in BUILD.gn should be listed in _IUSE or _IUSE_TRUE.

# USE flags whose default values are false.
_IUSE = [
    'amd_oemcrypto',
    'amd64',
    'android_vm_rvc',
    'arc_adb_sideloading',
    'arcpp',
    'arcvm',
    'arm',
    'asan',
    'attestation',
    'bluetooth_suspend_management',
    'buffet',
    'camera_feature_auto_framing',
    'camera_feature_face_detection',
    'camera_feature_hdrnet',
    'camera_feature_portrait_mode',
    'cdm_factory_daemon',
    'cellular',
    'cert_provision',
    'cfm',
    'cfm_enabled_device',
    'cheets',
    'chromeless_tty',
    'containers',
    'coverage',
    'cros_arm64',
    'cros_host',
    'cros_i686',
    'crosvm_virtio_video',
    'crosvm_wl_dmabuf',
    'crypto',
    'csme_emulator',
    'dbus',
    'device_mapper',
    'dhcpv6',
    'direncryption',
    'dlc',
    'double_extend_pcr_issue',
    'enable_slow_boot_notify',
    'encrypted_reboot_vault',
    'encrypted_stateful',
    'esdfs',
    'factory_runtime_probe',
    'fake_drivefs_launcher',
    'feedback',
    'fp_on_power_button',
    'fsverity',
    'ftdi_tpm',
    'fuzzer',
    'generic_tpm2',
    'hammerd_api',
    'houdini',
    'houdini64',
    'hw_details',
    'hwid_override',
    'iioservice',
    'intel_oemcrypto',
    'ipu6',
    'ipu6ep',
    'ipu6se',
    'iwlwifi_dump',
    'jetstream_routing',
    'kvm_guest',
    'kvm_host',
    'libglvnd',
    'lvm_stateful_partition',
    'manage_efi_boot_entries',
    'metrics_uploader',
    'ml_benchmark_drivers',
    'mojo',
    'mount_oop',
    'msan',
    'mtd',
    'ndk_translation',
    'ondevice_document_scanner',
    'ondevice_grammar',
    'ondevice_handwriting',
    'ondevice_handwriting_dlc',
    'ondevice_speech',
    'ondevice_text_suggestions',
    'opengles',
    'passive_metrics',
    'pinweaver',
    'pinweaver_csme',
    'postinstall_config_efi_and_legacy',
    'power_management',
    'pppoe',
    'prjquota',
    'profiling',
    'qrtr',
    'selinux',
    'slow_mount',
    'systemd',
    'tcmalloc',
    'test',
    'timers',
    'tpm',
    'tpm_dynamic',
    'tpm2',
    'tpm2_simulator',
    'ubsan',
    'udev',
    'user_session_isolation',
    'v4l2_codec',
    'vaapi',
    'video_cards_msm',
    'vm_borealis',
    'vpn',
    'vtpm_proxy',
    'vulkan',
    'wake_on_wifi',
    'wifi',
    'wired_8021x',
    'wpa3_sae',
]

# USE flags whose default values are true.
_IUSE_TRUE = [
    'chrome_kiosk_app',
    'chrome_network_proxy',
]


class Platform2(object):
    """Main builder logic for platform2"""

    def __init__(self,
                 use_flags=None,
                 board=None,
                 host=False,
                 libdir=None,
                 incremental=True,
                 verbose=False,
                 enable_tests=False,
                 cache_dir=None,
                 jobs=None,
                 platform_subdir=None):
        self.board = board
        self.host = host
        self.incremental = incremental
        self.jobs = jobs
        self.verbose = verbose
        self.platform_subdir = platform_subdir

        if use_flags is not None:
            self.use_flags = use_flags
        else:
            self.use_flags = portage_util.GetBoardUseFlags(self.board)

        if enable_tests:
            self.use_flags.add('test')

        if self.host:
            self.sysroot = '/'
        else:
            board_vars = self.get_portageq_envvars(['SYSROOT'], board=board)
            self.sysroot = board_vars['SYSROOT']

        if libdir:
            self.libdir = libdir
        else:
            self.libdir = '/usr/lib'

        if cache_dir:
            self.cache_dir = cache_dir
        else:
            self.cache_dir = os.path.join(
                self.sysroot, 'var/cache/portage/chromeos-base/platform2')

        self.libbase_ver = os.environ.get('BASE_VER', '')
        if not self.libbase_ver:
            # If BASE_VER variable not set, read the content of
            # $SYSROOT/usr/share/libchrome/BASE_VER
            # file which contains the default libchrome revision number.
            base_ver_file = os.path.join(self.sysroot,
                                         'usr/share/libchrome/BASE_VER')
            try:
                self.libbase_ver = osutils.ReadFile(base_ver_file).strip()
            except FileNotFoundError:
                # Software not depending on libchrome still uses platform2.py,
                # Instead of asserting here. Provide a human readable bad value
                # that is not supposed to be used.
                self.libbase_ver = 'NOT-INSTALLED'

    def get_src_dir(self):
        """Return the path to build tools and common GN files"""
        return os.path.realpath(os.path.dirname(__file__))

    def get_platform2_root(self):
        """Return the path to src/platform2"""
        return os.path.dirname(self.get_src_dir())

    def get_buildroot(self):
        """Return the path to the folder where build artifacts are located."""
        if not self.incremental:
            workdir = os.environ.get('WORKDIR')
            if workdir:
                # Matches $(cros-workon_get_build_dir) behavior.
                return os.path.join(workdir, 'build')
            else:
                return os.getcwd()
        else:
            return self.cache_dir

    def get_products_path(self):
        """Return the path to the folder where build product are located."""
        return os.path.join(self.get_buildroot(), 'out/Default')

    def get_portageq_envvars(self, varnames, board=None):
        """Returns the values of a given set of variables using portageq."""

        # See if the env already has these settings.  If so, grab them directly.
        # This avoids the need to specify --board at all most of the time.
        try:
            board_vars = {}
            for varname in varnames:
                board_vars[varname] = os.environ[varname]
            return board_vars
        except KeyError:
            pass

        if board is None and not self.host:
            board = self.board

        # Portage will set this to an incomplete list which breaks portageq
        # walking all of the repos.  Clear it and let the value be repopulated.
        os.environ.pop('PORTDIR_OVERLAY', None)

        return portage_util.PortageqEnvvars(varnames,
                                            board=board,
                                            allow_undefined=True)

    def get_build_environment(self):
        """Returns a dict of environment variables we will use to run GN.

        We do this to set the various toolchain names for the target board.
        """
        varnames = ['ARCH', 'CHOST', 'AR', 'CC', 'CXX', 'PKG_CONFIG']
        board_env = self.get_portageq_envvars(varnames)

        tool_names = {
            'AR': 'ar',
            'CC': 'gcc',
            'CXX': 'g++',
            'PKG_CONFIG': 'pkg-config',
        }

        env = {
            'ARCH': board_env.get('ARCH'),
        }
        for var, tool in tool_names.items():
            env['%s_target' % var] = (board_env[var] if board_env[var] else \
                                      '%s-%s' % (board_env['CHOST'], tool))

        return env

    def get_components_glob(self):
        """Return a glob of marker files for built components/projects.

        Each project spits out a file whilst building: we return a glob of them
        so we can install/test those projects or reset between compiles to
        ensure components that are no longer part of the build don't get
        installed.
        """
        return glob.glob(
            os.path.join(self.get_products_path(), 'gen/components_*'))

    def can_use_gn(self):
        """Returns true if GN can be used on configure.

        All packages in platform2/ should be configured by GN.
        """
        build_gn = os.path.join(self.get_platform2_root(), self.platform_subdir,
                                'BUILD.gn')
        return os.path.isfile(build_gn)

    def configure(self, args):
        """Runs the configure step of the Platform2 build.

        Creates the build root if it doesn't already exists.  Then runs the
        appropriate configure tool. Currently only GN is supported.
        """
        assert self.can_use_gn()
        # The args was used only for gyp.
        # TODO(crbug.com/767517): remove code for handling args.
        # There is a logic to in the platform eclass file, which detects a .gyp
        # file under project root and passes it to here an arg.
        if args:
            print('Warning: Args for GYP was given. We will no longer use GYP. '
                  'Ignoring it and continuing configuration with GN.')

        if not os.path.isdir(self.get_buildroot()):
            os.makedirs(self.get_buildroot())

        if not self.incremental:
            osutils.RmDir(self.get_products_path(), ignore_missing=True)

        self.configure_gn()

    def gen_common_args(self, should_parse_shell_string):
        """Generates common arguments for the tools to configure as a dict.

        Returned value types are str, bool or list of strs.
        Lists are returned only when should_parse_shell_string is set to True.
        """

        def flags(s):
            if should_parse_shell_string:
                return common_utils.parse_shell_args(s)
            return s

        args = {
            'OS': 'linux',
            'sysroot': self.sysroot,
            'libdir': self.libdir,
            'build_root': self.get_buildroot(),
            'platform2_root': self.get_platform2_root(),
            'libbase_ver': self.libbase_ver,
            'enable_exceptions': os.environ.get('CXXEXCEPTIONS', 0) == '1',
            'external_cflags': flags(os.environ.get('CFLAGS', '')),
            'external_cxxflags': flags(os.environ.get('CXXFLAGS', '')),
            'external_cppflags': flags(os.environ.get('CPPFLAGS', '')),
            'external_ldflags': flags(os.environ.get('LDFLAGS', '')),
        }
        return args

    def configure_gn_args(self):
        """Configure with GN.

        Generates flags to run GN with, and then runs GN.
        """

        def to_gn_string(s):
            return '"%s"' % s.replace('"', '\\"')

        def to_gn_list(strs):
            return '[%s]' % ','.join([to_gn_string(s) for s in strs])

        def to_gn_args_args(gn_args):
            for k, v in gn_args.items():
                if isinstance(v, bool):
                    v = str(v).lower()
                elif isinstance(v, list):
                    v = to_gn_list(v)
                elif isinstance(v, six.string_types):
                    v = to_gn_string(v)
                else:
                    raise AssertionError('Unexpected %s, %r=%r' %
                                         (type(v), k, v))
                yield '%s=%s' % (k.replace('-', '_'), v)

        buildenv = self.get_build_environment()

        # Map Gentoo ARCH to GN target_cpu. Sometimes they're the same.
        arch_to_target_cpu = {
            'amd64': 'x64',
            'mips': 'mipsel',
        }
        target_cpu = buildenv.get('ARCH')
        target_cpu = arch_to_target_cpu.get(target_cpu, target_cpu)
        assert target_cpu, '$ARCH is missing from the env'

        gn_args = {
            'platform_subdir':
            self.platform_subdir,
            'cc':
            buildenv.get('CC_target', buildenv.get('CC', '')),
            'cxx':
            buildenv.get('CXX_target', buildenv.get('CXX', '')),
            'ar':
            buildenv.get('AR_target', buildenv.get('AR', '')),
            'pkg-config':
            buildenv.get('PKG_CONFIG_target', buildenv.get('PKG_CONFIG', '')),
            'target_cpu':
            target_cpu,
            'target_os':
            'linux',
        }

        gn_args['clang_cc'] = 'clang' in gn_args['cc']
        gn_args['clang_cxx'] = 'clang' in gn_args['cxx']
        gn_args.update(self.gen_common_args(True))
        gn_args_args = list(to_gn_args_args(gn_args))

        # Set use flags as a scope.
        uses = {}
        for flag in _IUSE:
            uses[flag] = False
        for flag in _IUSE_TRUE:
            uses[flag] = True
        for x in self.use_flags:
            uses[x.replace('-', '_')] = True
        use_args = ['%s=%s' % (x, str(uses[x]).lower()) for x in uses]
        gn_args_args += ['use={%s}' % (' '.join(use_args))]

        return gn_args_args

    def configure_gn(self):
        """Configure with GN.

        Runs gn gen with generated flags.
        """
        gn_args_args = self.configure_gn_args()

        gn_args = ['gn', 'gen']
        if self.verbose:
            gn_args += ['-v']
        gn_args += [
            '--root=%s' % self.get_platform2_root(),
            '--args=%s' % ' '.join(gn_args_args),
            self.get_products_path(),
        ]
        cros_build_lib.run(gn_args,
                           extra_env=self.get_build_environment(),
                           cwd=self.get_platform2_root())

    def gn_desc(self, *args):
        """Describe BUILD.gn.

        Runs gn desc with generated flags.
        """
        gn_args_args = self.configure_gn_args()

        cmd = [
            'gn',
            'desc',
            self.get_products_path(),
            '//%s/*' % self.platform_subdir,
            '--root=%s' % self.get_platform2_root(),
            '--args=%s' % ' '.join(gn_args_args),
            '--format=json',
        ]
        cmd += args
        result = cros_build_lib.run(cmd,
                                    extra_env=self.get_build_environment(),
                                    cwd=self.get_platform2_root(),
                                    stdout=True,
                                    encoding='utf-8')
        return json.loads(result.output)

    def compile(self, args):
        """Runs the compile step of the Platform2 build.

        Removes any existing component markers that may exist (so we don't run
        tests/install for projects that have been disabled since the last
        build). Builds arguments for running Ninja and then runs Ninja.
        """
        for component in self.get_components_glob():
            os.remove(component)

        args = ['%s:%s' % (self.platform_subdir, x) for x in args]
        ninja_args = ['ninja', '-C', self.get_products_path()]
        if self.jobs:
            ninja_args += ['-j', str(self.jobs)]
        ninja_args += args

        if self.verbose:
            ninja_args.append('-v')

        if os.environ.get('NINJA_ARGS'):
            ninja_args.extend(os.environ['NINJA_ARGS'].split())

        cros_build_lib.run(ninja_args)

    def deviterate(self, args):
        """Runs the configure and compile steps of the Platform2 build.

        This is the default action, to allow easy iterative testing of changes
        as a developer.
        """
        self.configure([])
        self.compile(args)

    def configure_test(self):
        """Generates test options from GN."""

        def to_options(options):
            """Convert dict to shell string."""
            result = []
            for key, value in options.items():
                if isinstance(value, bool):
                    if value:
                        result.append('--%s' % key)
                    continue
                if key == 'raw':
                    result.append(value)
                    continue
                result.append('--%s=%s' % (key, value))
            return result

        conf = self.gn_desc('--all', '--type=executable')
        group_all = conf.get('//%s:all' % self.platform_subdir, {})
        group_all_deps = group_all.get('deps', [])
        options_list = []
        for target_name in group_all_deps:
            test_target = conf.get(target_name)
            outputs = test_target.get('outputs', [])
            if len(outputs) != 1:
                continue
            output = outputs[0]
            metadata = test_target.get('metadata', {})
            run_test = unwrap_value(metadata, '_run_test', False)
            if not run_test:
                continue
            test_config = unwrap_value(metadata, '_test_config', {})

            p2_test_py = os.path.join(self.get_src_dir(), 'platform2_test.py')
            options = [
                p2_test_py,
                '--action=run',
                '--sysroot=%s' % self.sysroot,
            ]
            if self.host:
                options += ['--host']
            p2_test_filter = os.environ.get('P2_TEST_FILTER')
            if p2_test_filter:
                options += ['--user_gtest_filter=%s' % p2_test_filter]
            options += to_options(test_config)
            options += ['--', output]

            options_list.append(options)
        return options_list

    def test_all(self, _args):
        """Runs all tests described from GN."""
        test_options_list = self.configure_test()
        for test_options in test_options_list:
            cros_build_lib.run(test_options, encoding='utf-8')

    def configure_install(self):
        """Generates installation commands of ebuild."""
        conf = self.gn_desc('--all')
        group_all = conf.get('//%s:all' % self.platform_subdir, {})
        group_all_deps = group_all.get('deps', [])
        config_group = collections.defaultdict(list)
        for target_name in group_all_deps:
            target_conf = conf.get(target_name, {})
            metadata = target_conf.get('metadata', {})
            install_config = unwrap_value(metadata, '_install_config')
            if not install_config:
                continue
            sources = install_config.get('sources')
            if not sources:
                continue
            install_path = install_config.get('install_path')
            outputs = install_config.get('outputs')
            symlinks = install_config.get('symlinks')
            recursive = install_config.get('recursive')
            options = install_config.get('options')
            command_type = install_config.get('type')
            config_key = (install_path, recursive, options, command_type)
            config_group[config_key].append((sources, outputs, symlinks))
        cmd_list = []
        for install_config, install_args in config_group.items():
            args = []
            # Commands to install sources without explicit outputs nor symlinks
            # can be merged into one. Concat all such sources.
            sources = sum([
                sources for sources, outputs, symlinks in install_args
                if not outputs and not symlinks
            ], [])
            if sources:
                args.append((sources, None, None))
            # Append all remaining sources/outputs/symlinks.
            args += [(sources, outputs, symlinks)
                     for sources, outputs, symlinks in install_args
                     if outputs or symlinks]
            # Generate the command line.
            install_path, recursive, options, command_type = install_config
            for sources, outputs, symlinks in args:
                cmd_list += ebuild_function.generate(sources=sources,
                                                     install_path=install_path,
                                                     outputs=outputs,
                                                     symlinks=symlinks,
                                                     recursive=recursive,
                                                     options=options,
                                                     command_type=command_type)
        return cmd_list

    def install(self, _args):
        """Outputs the installation commands of ebuild as a standard output."""
        install_cmd_list = self.configure_install()
        for install_cmd in install_cmd_list:
            # An error occurs at six.moves.shlex_quote when running pylint.
            # https://github.com/PyCQA/pylint/issues/1965
            # pylint: disable=too-many-function-args
            print(' '.join(six.moves.shlex_quote(arg) for arg in install_cmd))


def unwrap_value(metadata, attr, default=None):
    """Gets a value like dict.get() with unwrapping it."""
    data = metadata.get(attr)
    if data is None:
        return default
    return data[0]


def GetParser():
    """Return a command line parser."""
    actions = ['configure', 'compile', 'deviterate', 'test_all', 'install']

    parser = commandline.ArgumentParser(description=__doc__)
    parser.add_argument('--action',
                        default='deviterate',
                        choices=actions,
                        help='action to run')
    parser.add_argument('--board', help='board to build for')
    parser.add_argument('--cache_dir',
                        help='directory to use as cache for incremental build')
    parser.add_argument('--disable_incremental',
                        action='store_false',
                        dest='incremental',
                        help='disable incremental build')
    parser.add_argument('--enable_tests',
                        action='store_true',
                        help='build and run tests')
    parser.add_argument('--host',
                        action='store_true',
                        help="specify that we're building for the host")
    parser.add_argument('--libdir',
                        help='the libdir for the specific board, eg /usr/lib64')
    parser.add_argument('--use_flags',
                        action='split_extend',
                        help='USE flags to enable')
    parser.add_argument('-j',
                        '--jobs',
                        type=int,
                        default=None,
                        help='number of jobs to run in parallel')
    parser.add_argument('--platform_subdir',
                        required=True,
                        help='subdir in platform2 where the package is located')
    parser.add_argument('args', nargs='*')

    return parser


def main(argv):
    parser = GetParser()

    # Temporary measure. Moving verbose argument, but can't do it all in one
    # sweep due to CROS_WORKON_MANUAL_UPREVed packages. Use parse_known_args
    # and manually handle verbose parsing to maintain compatibility.
    options, unknown = parser.parse_known_args(argv)

    if not hasattr(options, 'verbose'):
        options.verbose = '--verbose' in unknown

    if '--verbose' in unknown:
        unknown.remove('--verbose')
    if unknown:
        parser.error('Unrecognized arguments: %s' % unknown)

    if options.host and options.board:
        raise AssertionError('You must provide only one of --board or --host')

    if not options.verbose:
        # Should convert to cros_build_lib.BooleanShellValue.
        options.verbose = (os.environ.get('VERBOSE', '0') == '1')
    p2 = Platform2(options.use_flags,
                   options.board,
                   options.host,
                   options.libdir,
                   options.incremental,
                   options.verbose,
                   options.enable_tests,
                   options.cache_dir,
                   jobs=options.jobs,
                   platform_subdir=options.platform_subdir)
    getattr(p2, options.action)(options.args)


if __name__ == '__main__':
    commandline.ScriptWrapperMain(lambda _: main)
