# This Source Code Form is subject to the terms of the Mozilla Public
# License, v. 2.0. If a copy of the MPL was not distributed with this
# file, You can obtain one at http://mozilla.org/MPL/2.0/.

import itertools
import json
import os
from collections import defaultdict
from operator import itemgetter

import mozpack.path as mozpath
from mozpack.chrome.manifest import parse_manifest_line

from mozbuild.backend.base import BuildBackend
from mozbuild.dirutils import mkdir
from mozbuild.frontend.context import (
    VARIABLES,
    Context,
    ObjDirPath,
    Path,
    RenamedSourcePath,
)
from mozbuild.frontend.data import (
    BaseProgram,
    ChromeManifestEntry,
    ConfigFileSubstitution,
    Exports,
    FinalTargetFiles,
    FinalTargetPreprocessedFiles,
    GeneratedFile,
    HostLibrary,
    HostSources,
    IPDLCollection,
    LocalizedFiles,
    LocalizedPreprocessedFiles,
    SandboxedWasmLibrary,
    SharedLibrary,
    Sources,
    StaticLibrary,
    UnifiedSources,
    WebIDLCollection,
    XPCOMComponentManifests,
    XPIDLModule,
)
from mozbuild.jar import DeprecatedJarManifest, JarManifestParser
from mozbuild.preprocessor import Preprocessor


class XPIDLManager:
    """Helps manage XPCOM IDLs in the context of the build system."""

    class Module:
        def __init__(self):
            self.idl_files = set()
            self.directories = set()
            self._stems = set()

        def add_idls(self, idls):
            self.idl_files.update(idl.full_path for idl in idls)
            self.directories.update(mozpath.dirname(idl.full_path) for idl in idls)
            self._stems.update(
                mozpath.splitext(mozpath.basename(idl))[0] for idl in idls
            )

        def stems(self):
            return iter(self._stems)

    def __init__(self, config):
        self.config = config
        self.topsrcdir = config.topsrcdir
        self.topobjdir = config.topobjdir

        self._idls = set()
        self.modules = defaultdict(self.Module)

    def link_module(self, module):
        """Links an XPIDL module with with this instance."""
        for idl in module.idl_files:
            basename = mozpath.basename(idl.full_path)

            if basename in self._idls:
                raise Exception("IDL already registered: %s" % basename)
            self._idls.add(basename)

        self.modules[module.name].add_idls(module.idl_files)

    def idl_stems(self):
        """Return an iterator of stems of the managed IDL files.

        The stem of an IDL file is the basename of the file with no .idl extension.
        """
        return itertools.chain(*[m.stems() for m in self.modules.values()])


class BinariesCollection:
    """Tracks state of binaries produced by the build."""

    def __init__(self):
        self.shared_libraries = []
        self.programs = []


class CommonBackend(BuildBackend):
    """Holds logic common to all build backends."""

    def _init(self):
        self._idl_manager = XPIDLManager(self.environment)
        self._binaries = BinariesCollection()
        self._configs = set()
        self._generated_sources = set()

    def consume_object(self, obj):
        self._configs.add(obj.config)

        if isinstance(obj, XPIDLModule):
            # TODO bug 1240134 tracks not processing XPIDL files during
            # artifact builds.
            self._idl_manager.link_module(obj)

        elif isinstance(obj, ConfigFileSubstitution):
            # Do not handle ConfigFileSubstitution for Makefiles. Leave that
            # to other
            if mozpath.basename(obj.output_path) == "Makefile":
                return False
            with self._get_preprocessor(obj) as pp:
                pp.do_include(obj.input_path)
            self.backend_input_files.add(obj.input_path)

        elif isinstance(obj, WebIDLCollection):
            self._handle_webidl_collection(obj)

        elif isinstance(obj, IPDLCollection):
            self._handle_ipdl_sources(
                obj.objdir,
                list(sorted(obj.all_sources())),
                list(sorted(obj.all_preprocessed_sources())),
                list(sorted(obj.all_regular_sources())),
            )

        elif isinstance(obj, XPCOMComponentManifests):
            self._handle_xpcom_collection(obj)

        elif isinstance(obj, UnifiedSources):
            if obj.generated_files:
                self._handle_generated_sources(obj.generated_files)

            # Unified sources aren't relevant to artifact builds.
            if self.environment.is_artifact_build:
                return True

            if obj.have_unified_mapping:
                self._write_unified_files(obj.unified_source_mapping, obj.objdir)
            if hasattr(self, "_process_unified_sources"):
                self._process_unified_sources(obj)

        elif isinstance(obj, BaseProgram):
            self._binaries.programs.append(obj)
            return False

        elif isinstance(obj, SharedLibrary):
            self._binaries.shared_libraries.append(obj)
            return False

        elif isinstance(obj, SandboxedWasmLibrary):
            self._handle_generated_sources(
                [mozpath.join(obj.relobjdir, f"{obj.basename}.h")]
            )
            return False

        elif isinstance(obj, (Sources, HostSources)):
            if obj.generated_files:
                self._handle_generated_sources(obj.generated_files)
            return False

        elif isinstance(obj, GeneratedFile):
            for f in obj.outputs:
                if f == "cbindgen-metadata.json":
                    # FIXME (bug 1865785)
                    #
                    # The content of cbindgen-metadata.json is not sorted and
                    # the order is not consistent across multiple runs.
                    #
                    # Exclude this file in order to avoid breaking the
                    # taskcluster/kinds/diffoscope/reproducible.yml jobs.
                    continue
                fullpath = ObjDirPath(obj._context, "!" + f).full_path
                self._handle_generated_sources([fullpath])
            return False

        elif isinstance(obj, Exports):
            objdir_files = [
                f.full_path
                for path, files in obj.files.walk()
                for f in files
                if isinstance(f, ObjDirPath)
            ]
            if objdir_files:
                self._handle_generated_sources(objdir_files)
            return False

        elif isinstance(obj, FinalTargetPreprocessedFiles):
            for path, files in obj.files.walk():
                for f in files:
                    basename = FinalTargetPreprocessedFiles.get_obj_basename(f)
                    relpath = mozpath.join(obj.install_target, path, basename)
                    self._handle_generated_sources(
                        [ObjDirPath(obj._context, "!/" + relpath).full_path]
                    )
            return False

        else:
            return False

        return True

    def consume_finished(self):
        if len(self._idl_manager.modules):
            self._write_rust_xpidl_summary(self._idl_manager)
            self._handle_idl_manager(self._idl_manager)
            self._handle_xpidl_sources()

        for config in self._configs:
            self.backend_input_files.add(config.source)

        # Write out a machine-readable file describing binaries.
        topobjdir = self.environment.topobjdir
        with self._write_file(mozpath.join(topobjdir, "binaries.json")) as fh:
            d = {
                "shared_libraries": sorted(
                    (s.to_dict() for s in self._binaries.shared_libraries),
                    key=itemgetter("basename"),
                ),
                "programs": sorted(
                    (p.to_dict() for p in self._binaries.programs),
                    key=itemgetter("program"),
                ),
            }
            json.dump(d, fh, sort_keys=True, indent=4)

        # Write out a file listing generated sources.
        with self._write_file(mozpath.join(topobjdir, "generated-sources.json")) as fh:
            d = {"sources": sorted(self._generated_sources)}
            json.dump(d, fh, sort_keys=True, indent=4)

    def _expand_libs(self, input_bin):
        os_libs = []
        shared_libs = []
        static_libs = []
        objs = []

        seen_objs = set()
        seen_libs = set()

        def add_objs(lib):
            for o in lib.objs:
                if o in seen_objs:
                    continue

                seen_objs.add(o)
                objs.append(o)

        def expand(lib, recurse_objs, system_libs):
            if isinstance(lib, (HostLibrary, StaticLibrary, SandboxedWasmLibrary)):
                if lib.no_expand_lib:
                    static_libs.append(lib)
                    recurse_objs = False
                elif recurse_objs:
                    add_objs(lib)

                for l in lib.linked_libraries:
                    expand(l, recurse_objs, system_libs)

                if system_libs:
                    for l in lib.linked_system_libs:
                        if l not in seen_libs:
                            seen_libs.add(l)
                            os_libs.append(l)

            elif isinstance(lib, SharedLibrary):
                if lib not in seen_libs:
                    seen_libs.add(lib)
                    shared_libs.append(lib)

        add_objs(input_bin)

        system_libs = not isinstance(
            input_bin, (HostLibrary, StaticLibrary, SandboxedWasmLibrary)
        )
        for lib in input_bin.linked_libraries:
            if isinstance(lib, (HostLibrary, StaticLibrary, SandboxedWasmLibrary)):
                expand(lib, True, system_libs)
            elif isinstance(lib, SharedLibrary):
                if lib not in seen_libs:
                    seen_libs.add(lib)
                    shared_libs.append(lib)

        for lib in input_bin.linked_system_libs:
            if lib not in seen_libs:
                seen_libs.add(lib)
                os_libs.append(lib)

        return (objs, shared_libs, os_libs, static_libs)

    def _make_ar_response_file(self, objdir, objs, name):
        if not objs:
            return None

        if not self.environment.substs.get("AR_SUPPORTS_RESPONSE_FILE"):
            return None

        response_file_path = mozpath.join(objdir, name)
        ref = "@" + response_file_path
        content = "\n".join(objs)

        mkdir(objdir)
        with self._write_file(response_file_path) as fh:
            fh.write(content)

        return ref

    def _make_list_file(self, kind, objdir, objs, name):
        if not objs:
            return None
        if kind == "target":
            list_style = self.environment.substs.get("EXPAND_LIBS_LIST_STYLE")
        else:
            # The host compiler is not necessarily the same kind as the target
            # compiler, so we can't be sure EXPAND_LIBS_LIST_STYLE is the right
            # style to use ; however, all compilers support the `list` type, so
            # use that. That doesn't cause any practical problem because where
            # it really matters to use something else than `list` is when
            # linking tons of objects (because of command line argument limits),
            # which only really happens for libxul.
            list_style = "list"
        list_file_path = mozpath.join(objdir, name)
        objs = [os.path.relpath(o, objdir) for o in objs]
        content = "\n".join(objs)
        if list_style == "filelist":
            ref = "-Wl,-filelist," + list_file_path
        elif list_style == "linkerlist":
            ref = "-Wl,@" + list_file_path
        elif list_style == "list":
            ref = "@" + list_file_path
        else:
            return None

        mkdir(objdir)
        with self._write_file(list_file_path) as fh:
            fh.write(content)

        return ref

    def _handle_generated_sources(self, files):
        self._generated_sources.update(
            mozpath.relpath(f, self.environment.topobjdir) for f in files
        )

    def _handle_xpidl_sources(self):
        bindings_rt_dir = mozpath.join(
            self.environment.topobjdir, "dist", "xpcrs", "rt"
        )
        bindings_bt_dir = mozpath.join(
            self.environment.topobjdir, "dist", "xpcrs", "bt"
        )
        include_dir = mozpath.join(self.environment.topobjdir, "dist", "include")

        self._handle_generated_sources(
            itertools.chain.from_iterable(
                (
                    mozpath.join(include_dir, "%s.h" % stem),
                    mozpath.join(bindings_rt_dir, "%s.rs" % stem),
                    mozpath.join(bindings_bt_dir, "%s.rs" % stem),
                )
                for stem in self._idl_manager.idl_stems()
            )
        )

    def _handle_webidl_collection(self, webidls):
        bindings_dir = mozpath.join(self.environment.topobjdir, "dom", "bindings")

        all_inputs = set(webidls.all_static_sources())
        for s in webidls.all_non_static_basenames():
            all_inputs.add(mozpath.join(bindings_dir, s))

        generated_events_stems = webidls.generated_events_stems()
        exported_stems = webidls.all_regular_stems()

        # The WebIDL manager reads configuration from a JSON file. So, we
        # need to write this file early.
        o = dict(
            webidls=sorted(all_inputs),
            generated_events_stems=sorted(generated_events_stems),
            exported_stems=sorted(exported_stems),
            example_interfaces=sorted(webidls.example_interfaces),
        )

        file_lists = mozpath.join(bindings_dir, "file-lists.json")
        with self._write_file(file_lists) as fh:
            json.dump(o, fh, sort_keys=True, indent=2)

        import mozwebidlcodegen

        manager = mozwebidlcodegen.create_build_system_manager(
            self.environment.topsrcdir,
            self.environment.topobjdir,
            mozpath.join(self.environment.topobjdir, "dist"),
        )
        self._handle_generated_sources(manager.expected_build_output_files())
        self._write_unified_files(
            webidls.unified_source_mapping, bindings_dir, poison_windows_h=True
        )
        self._handle_webidl_build(
            bindings_dir,
            webidls.unified_source_mapping,
            webidls,
            manager.expected_build_output_files(),
            manager.GLOBAL_DEFINE_FILES,
        )

    def _handle_xpcom_collection(self, manifests):
        components_dir = mozpath.join(manifests.topobjdir, "xpcom", "components")

        # The code generators read their configuration from this file, so it
        # needs to be written early.
        o = dict(manifests=sorted(manifests.all_sources()))

        conf_file = mozpath.join(components_dir, "manifest-lists.json")
        with self._write_file(conf_file) as fh:
            json.dump(o, fh, sort_keys=True, indent=2)

    def _write_unified_file(
        self, unified_file, source_filenames, output_directory, poison_windows_h=False
    ):
        with self._write_file(mozpath.join(output_directory, unified_file)) as f:
            f.write("#define MOZ_UNIFIED_BUILD\n")
            includeTemplate = '#include "%(cppfile)s"'
            if poison_windows_h:
                includeTemplate += (
                    "\n"
                    "#if defined(_WINDOWS_) && !defined(MOZ_WRAPPED_WINDOWS_H)\n"
                    '#pragma message("wrapper failure reason: " MOZ_WINDOWS_WRAPPER_DISABLED_REASON)\n'  # noqa
                    '#error "%(cppfile)s included unwrapped windows.h"\n'
                    "#endif"
                )
            includeTemplate += (
                "\n"
                "#ifdef PL_ARENA_CONST_ALIGN_MASK\n"
                '#error "%(cppfile)s uses PL_ARENA_CONST_ALIGN_MASK, '
                'so it cannot be built in unified mode."\n'
                "#undef PL_ARENA_CONST_ALIGN_MASK\n"
                "#endif\n"
                "#ifdef INITGUID\n"
                '#error "%(cppfile)s defines INITGUID, '
                'so it cannot be built in unified mode."\n'
                "#undef INITGUID\n"
                "#endif"
            )
            f.write(
                "\n".join(includeTemplate % {"cppfile": s} for s in source_filenames)
            )

    def _write_unified_files(
        self, unified_source_mapping, output_directory, poison_windows_h=False
    ):
        for unified_file, source_filenames in unified_source_mapping:
            self._write_unified_file(
                unified_file, source_filenames, output_directory, poison_windows_h
            )

    def localized_path(self, relativesrcdir, filename):
        """Return the localized path for a file.

        Given ``relativesrcdir``, a path relative to the topsrcdir, return a path to ``filename``
        from the current locale as specified by ``MOZ_UI_LOCALE``, using ``L10NBASEDIR`` as the
        parent directory for non-en-US locales.
        """
        ab_cd = self.environment.substs["MOZ_UI_LOCALE"]
        l10nbase = mozpath.join(self.environment.substs["L10NBASEDIR"], ab_cd)
        # Filenames from LOCALIZED_FILES will start with en-US/.
        if filename.startswith("en-US/"):
            e, filename = filename.split("en-US/")
            assert not e
        if ab_cd == "en-US":
            return mozpath.join(
                self.environment.topsrcdir, relativesrcdir, "en-US", filename
            )
        if mozpath.basename(relativesrcdir) == "locales":
            l10nrelsrcdir = mozpath.dirname(relativesrcdir)
        else:
            l10nrelsrcdir = relativesrcdir
        return mozpath.join(l10nbase, l10nrelsrcdir, filename)

    def _consume_jar_manifest(self, obj):
        # Ideally, this would all be handled somehow in the emitter, but
        # this would require all the magic surrounding l10n and addons in
        # the recursive make backend to die, which is not going to happen
        # any time soon enough.
        # Notably missing:
        # - DEFINES from config/config.mk
        # - The equivalent of -e when USE_EXTENSION_MANIFEST is set in
        #   moz.build, but it doesn't matter in dist/bin.
        pp = Preprocessor()
        if obj.defines:
            pp.context.update(obj.defines.defines)
        pp.context.update(self.environment.defines)
        ab_cd = obj.config.substs["MOZ_UI_LOCALE"]
        pp.context.update(AB_CD=ab_cd)
        pp.out = JarManifestParser()
        try:
            pp.do_include(obj.path.full_path)
        except DeprecatedJarManifest as e:
            raise DeprecatedJarManifest(
                "Parsing error while processing %s: %s" % (obj.path.full_path, e)
            )
        self.backend_input_files |= pp.includes

        for jarinfo in pp.out:
            jar_context = Context(
                allowed_variables=VARIABLES, config=obj._context.config
            )
            jar_context.push_source(obj._context.main_path)
            jar_context.push_source(obj.path.full_path)

            install_target = obj.install_target
            if jarinfo.base:
                install_target = mozpath.normpath(
                    mozpath.join(install_target, jarinfo.base)
                )
            jar_context["FINAL_TARGET"] = install_target
            if obj.defines:
                jar_context["DEFINES"] = obj.defines.defines
            files = jar_context["FINAL_TARGET_FILES"]
            files_pp = jar_context["FINAL_TARGET_PP_FILES"]
            localized_files = jar_context["LOCALIZED_FILES"]
            localized_files_pp = jar_context["LOCALIZED_PP_FILES"]

            for e in jarinfo.entries:
                if e.is_locale:
                    if jarinfo.relativesrcdir:
                        src = "/%s" % jarinfo.relativesrcdir
                    else:
                        src = ""
                    src = mozpath.join(src, "en-US", e.source)
                else:
                    src = e.source

                src = Path(jar_context, src)

                if "*" not in e.source and not os.path.exists(src.full_path):
                    if e.is_locale:
                        raise Exception(
                            "%s: Cannot find %s (tried %s)"
                            % (obj.path, e.source, src.full_path)
                        )
                    if e.source.startswith("/"):
                        src = Path(jar_context, "!" + e.source)
                    else:
                        # This actually gets awkward if the jar.mn is not
                        # in the same directory as the moz.build declaring
                        # it, but it's how it works in the recursive make,
                        # not that anything relies on that, but it's simpler.
                        src = Path(obj._context, "!" + e.source)

                output_basename = mozpath.basename(e.output)
                if output_basename != src.target_basename:
                    src = RenamedSourcePath(jar_context, (src, output_basename))
                path = mozpath.dirname(mozpath.join(jarinfo.name, e.output))

                if e.preprocess:
                    if "*" in e.source:
                        raise Exception(
                            "%s: Wildcards are not supported with "
                            "preprocessing" % obj.path
                        )
                    if e.is_locale:
                        localized_files_pp[path] += [src]
                    else:
                        files_pp[path] += [src]
                else:
                    if e.is_locale:
                        localized_files[path] += [src]
                    else:
                        files[path] += [src]

            if files:
                self.consume_object(FinalTargetFiles(jar_context, files))
            if files_pp:
                self.consume_object(FinalTargetPreprocessedFiles(jar_context, files_pp))
            if localized_files:
                self.consume_object(LocalizedFiles(jar_context, localized_files))
            if localized_files_pp:
                self.consume_object(
                    LocalizedPreprocessedFiles(jar_context, localized_files_pp)
                )

            for m in jarinfo.chrome_manifests:
                entry = parse_manifest_line(
                    mozpath.dirname(jarinfo.name),
                    m.replace("%", mozpath.basename(jarinfo.name) + "/"),
                )
                self.consume_object(
                    ChromeManifestEntry(
                        jar_context, "%s.manifest" % jarinfo.name, entry
                    )
                )

    def _write_rust_xpidl_summary(self, manager):
        """Write out a rust file which includes the generated xpcom rust modules"""
        topobjdir = self.environment.topobjdir

        include_tmpl = 'include!(mozbuild::objdir_path!("dist/xpcrs/%s/%s.rs"))'

        # Ensure deterministic output files.
        stems = sorted(manager.idl_stems())

        with self._write_file(
            mozpath.join(topobjdir, "dist", "xpcrs", "rt", "all.rs")
        ) as fh:
            fh.write("// THIS FILE IS GENERATED - DO NOT EDIT\n\n")
            for stem in stems:
                fh.write(include_tmpl % ("rt", stem))
                fh.write(";\n")

        with self._write_file(
            mozpath.join(topobjdir, "dist", "xpcrs", "bt", "all.rs")
        ) as fh:
            fh.write("// THIS FILE IS GENERATED - DO NOT EDIT\n\n")
            fh.write("&[\n")
            for stem in stems:
                fh.write(include_tmpl % ("bt", stem))
                fh.write(",\n")
            fh.write("]\n")
