# Copyright 2014 Open Source Robotics Foundation, Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#     http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

"""This module implements a class for representing a catkin workspace context"""

import os
import re
import sys

from . import metadata

from .common import getcwd
from .common import printed_fill
from .common import remove_ansi_escape
from .common import terminal_width

from .metadata import find_enclosing_workspace

from .resultspace import get_resultspace_environment

from .terminal_color import ColorMapper

color_mapper = ColorMapper()
clr = color_mapper.clr


class Context(object):

    """Encapsulates a catkin workspace's settings which affect build results.

    This class will validate some of the settings on assignment using the
    filesystem, but it will never modify the filesystem. For instance, it will
    raise an exception if the source space does not exist, but it will not
    create a folder for the build space if it does not already exist.

    This context can be locked, so that changing the members is prevented.
    """

    CATKIN_SPACES_GROUP = 'catkin_tools.spaces'

    SPACES = {}

    STORED_KEYS = [
        'extend_path',
        'devel_layout',
        'install',
        'isolate_install',
        'cmake_args',
        'make_args',
        'jobs_args',
        'use_internal_make_jobserver',
        'use_env_cache',
        'catkin_make_args',
        'buildlist',
        'skiplist',
        'authors',
        'maintainers',
        'licenses',
        'extends',
    ]

    EXTRA_KEYS = [
        'workspace',
        'profile',
        'space_suffix',
    ]

    KEYS = []

    @classmethod
    def _create_space_methods(cls, space):
        def space_abs_getter(self):
            return getattr(self, '__%s_space_abs' % space)

        def space_getter(self):
            return getattr(self, '__%s_space' % space)

        def space_setter(self, value):
            if self.__locked:
                raise RuntimeError("Setting of context members is not allowed while locked.")
            setattr(self, '__%s_space' % space, value)
            setattr(self, '__%s_space_abs' % space, os.path.realpath(os.path.join(self.workspace, value)))

        def space_exists(self):
            """
            Returns true if the space exists.
            """
            space_abs = getattr(self, '__%s_space_abs' % space)
            return os.path.exists(space_abs) and os.path.isdir(space_abs)

        def package_space(self, package):
            """
            Get the package specific path in a space
            """
            space_abs = getattr(self, '__%s_space_abs' % space)
            return os.path.join(space_abs, package.name)

        setattr(cls, '%s_space' % space, property(space_getter, space_setter))
        setattr(cls, '%s_space_abs' % space, property(space_abs_getter))
        setattr(cls, '%s_space_exists' % space, space_exists)

        package_space_name = "package_%s_space" % space
        if not hasattr(cls, package_space_name):
            setattr(cls, package_space_name, package_space)

    @classmethod
    def setup_space_keys(cls):
        """
        To be called one time on initial use. Initializes the SPACE_KEYS
        class members and associated member functions based on available
        space plugins.
        """
        if cls.KEYS:
            return

        from pkg_resources import iter_entry_points

        for entry_point in iter_entry_points(group=cls.CATKIN_SPACES_GROUP):
            ep_dict = entry_point.load()
            cls.STORED_KEYS.append(entry_point.name + '_space')
            cls.SPACES[entry_point.name] = ep_dict
            cls._create_space_methods(entry_point.name)

        cls.KEYS = cls.STORED_KEYS + cls.EXTRA_KEYS

    @classmethod
    def load(
        cls,
        workspace_hint=None,
        profile=None,
        opts=None,
        strict=False,
        append=False,
        remove=False,
        load_env=True
    ):
        """Load a context from a given workspace and profile with optional
        modifications.

        This function will try to load a given context from the specified
        workspace with the following resolution strategy:
         - existing workspace enclosing given workspace path
         - existing workspace enclosing "."
         - given workspace path
         - "."

        If a workspace cannot be found, it will assume that the user is
        specifying a new workspace, unless `strict=True` is given. In this
        latter case, this function will return None.

        :param workspace_hint: The hint used to find a workspace (see description for more details)
        :type workspace_hint: str
        :param profile: The profile to load the context from, if the profile is None, the active profile is used
        :type profile: str
        :param opts: An argparse options namespace containing context keys to override stored context keys
        :type opts: namespace
        :param strict: Causes this function to return None if a workspace isn't found
        :type strict: bool
        :param append: Appends any list-type opts to existing opts
        :type append: bool
        :param remove: Removes any list-type opts from existing opts
        :type remove: bool
        :param load_env: Control whether the context loads the resultspace
        environment for the full build context
        :type load_env: bool

        :returns: A potentially valid Context object constructed from the given arguments
        :rtype: Context
        """
        Context.setup_space_keys()

        # Initialize dictionary version of opts namespace
        opts_vars = vars(opts) if opts else {}

        # Get the workspace (either the given directory or the enclosing ws)
        workspace_hint = workspace_hint or opts_vars.get('workspace', None) or getcwd()
        workspace = find_enclosing_workspace(workspace_hint)
        if not workspace:
            if strict or not workspace_hint:
                return None
            else:
                workspace = workspace_hint
        opts_vars['workspace'] = workspace

        # Get the active profile
        profile = profile or opts_vars.get('profile', None) or metadata.get_active_profile(workspace)
        opts_vars['profile'] = profile

        # Initialize empty metadata/args
        config_metadata = {}
        context_args = {}

        # Get the metadata stored in the workspace if it was found
        if workspace:
            key_origins = {}
            visited_profiles = []

            def get_metadata_recursive(profile):
                config_metadata = metadata.get_metadata(workspace, profile, 'config')
                visited_profiles.append(profile)
                if "extends" in config_metadata.keys() and config_metadata["extends"] is not None:
                    base_profile = config_metadata["extends"]
                    if base_profile in visited_profiles:
                        raise IOError("Profile dependency circle detected at dependency from '%s' to '%s'!"
                                      % (profile, base_profile))
                    base = get_metadata_recursive(base_profile)
                    base.update(config_metadata)
                    for key in config_metadata.keys():
                        key_origins[key] = profile
                    return base
                for key in config_metadata.keys():
                    key_origins[key] = profile
                return config_metadata

            config_metadata = get_metadata_recursive(profile)

            context_args.update(config_metadata)

        # User-supplied args are used to update stored args
        # Only update context args with given opts which are not none
        for (k, v) in opts_vars.items():
            if k in Context.KEYS and v is not None:
                # Handle list-type arguments with append/remove functionality
                if type(context_args.get(k, None)) is list and type(v) is list:
                    if append:
                        context_args[k] += v
                    elif remove:
                        context_args[k] = [w for w in context_args[k] if w not in v]
                    else:
                        context_args[k] = v
                    if workspace:
                        key_origins[k] = profile
                else:
                    context_args[k] = v
                    if workspace:
                        key_origins[k] = profile

        # When --no-make-args and no other jobs args are given, clean jobs args too
        if 'make_args' in opts_vars and opts_vars['make_args'] == [] and opts_vars['jobs_args'] is None:
            context_args['jobs_args'] = []

        context_args["key_origins"] = key_origins

        # Create the build context
        ctx = Context(**context_args)

        # Don't load the cmake config if it's not needed
        if load_env:
            ctx.load_env()

        return ctx

    @classmethod
    def save(cls, context):
        """Save a context in the associated workspace and profile."""

        data = context.get_stored_dict()
        files = {}

        def save_in_file(file, key, value):
            if file in files.keys():
                files[file][key] = value
            else:
                files[file] = {key: value}

        for key, val in data.items():
            if context.extends is not None and key in context.key_origins:
                save_in_file(context.key_origins[key], key, val)
            else:
                save_in_file(context.profile, key, val)

        for profile, content in files.items():
            metadata.update_metadata(
                context.workspace,
                profile,
                'config',
                content)

    def get_stored_dict(self):
        """Get the context parameters which should be stored persistently."""
        return dict([(k, getattr(self, k)) for k in Context.STORED_KEYS])

    def __init__(
        self,
        workspace=None,
        profile=None,
        extend_path=None,
        devel_layout=None,
        install=False,
        isolate_install=False,
        cmake_args=None,
        make_args=None,
        jobs_args=None,
        use_internal_make_jobserver=True,
        use_env_cache=False,
        catkin_make_args=None,
        space_suffix=None,
        buildlist=None,
        skiplist=None,
        authors=None,
        maintainers=None,
        licenses=None,
        extends=None,
        key_origins={},
        **kwargs
    ):
        """Creates a new Context object, optionally initializing with parameters

        :param workspace: root of the workspace, defaults to the enclosing workspace
        :type workspace: str
        :param profile: profile name, defaults to the default profile
        :type profile: str
        :param extend_path: catkin result-space to extend
        :type extend_path: str
        :param source_space: relative location of source space, defaults to '<workspace>/src'
        :type source_space: str
        :param log_space: relative location of log space, defaults to '<workspace>/logs'
        :type log_space: str
        :param build_space: relative target location of build space, defaults to '<workspace>/build'
        :type build_space: str
        :param devel_space: relative target location of devel space, defaults to '<workspace>/devel'
        :type devel_space: str
        :param install_space: relative target location of install space, defaults to '<workspace>/install'
        :type install_space: str
        :param isolate_devel: each package will have its own develspace if True, default is False
        :type isolate_devel: bool
        :param install: packages will be installed by invoking ``make install``, defaults to False
        :type install: bool
        :param isolate_install: packages will be installed to separate folders if True, defaults to False
        :type isolate_install: bool
        :param cmake_args: extra cmake arguments to be passed to cmake for each package
        :type cmake_args: list
        :param make_args: extra make arguments to be passed to make for each package
        :type make_args: list
        :param jobs_args: -j and -l jobs args
        :type jobs_args: list
        :param use_internal_make_jobserver: true if this configuration should use an internal make jobserver
        :type use_internal_make_jobserver: bool
        :param use_env_cache: true if this configuration should cache job environments loaded from resultspaces
        :type use_env_cache: bool
        :param catkin_make_args: extra make arguments to be passed to make for each catkin package
        :type catkin_make_args: list
        :param space_suffix: suffix for build, devel, and install spaces which are not explicitly set.
        :type space_suffix: str
        :param buildlist: a list of packages to build by default
        :type buildlist: list
        :param skiplist: a list of packages to ignore by default
        :type skiplist: list
        :raises: ValueError if workspace or source space does not exist
        :type authors: list
        :param authors: a list of default authors
        :type maintainers: list
        :param maintainers: a list of default maintainers
        :type licenses: list
        :param licenses: a list of default licenses
        :type extends: string
        :param extends: the name of a profile to use as a base and inherit settings from
        """
        self.__locked = False

        # Validation is done on assignment
        self.workspace = workspace

        self.extend_path = extend_path if extend_path else None
        self.key_origins = key_origins

        self.profile = profile

        # Handle *space assignment and defaults
        for space, space_dict in Context.SPACES.items():
            key_name = space + '_space'
            default = space_dict['default']
            value = kwargs.pop(key_name, default)
            if value == default and space_suffix and space != 'source':
                value += space_suffix
            setattr(self, key_name, value)

        # Check for unhandled context options
        if len(kwargs) > 0:
            print('Warning: Unhandled config context options: {}'.format(kwargs), file=sys.stderr)

        self.destdir = os.environ.get('DESTDIR', None)

        # Handle package buildlist/skiplist
        self.buildlist = buildlist or []
        self.skiplist = skiplist or []

        # Handle default authors/maintainers
        self.authors = authors or []
        self.maintainers = maintainers or []
        self.licenses = licenses or ['TODO']

        # Handle build options
        self.devel_layout = devel_layout if devel_layout else 'linked'
        self.install = install
        self.isolate_install = isolate_install

        # Handle additional cmake and make arguments
        self.cmake_args = cmake_args or []
        self.make_args = make_args or []
        self.jobs_args = jobs_args or []
        self.use_internal_make_jobserver = use_internal_make_jobserver
        self.use_env_cache = use_env_cache
        self.catkin_make_args = catkin_make_args or []

        # List of packages in the workspace is set externally
        self.packages = []

        # List of warnings about the workspace is set internally
        self.warnings = []

        # Initialize environment settings set by load_env
        self.manual_cmake_prefix_path = None
        self.cached_cmake_prefix_path = None
        self.env_cmake_prefix_path = None
        self.cmake_prefix_path = None

        self.extends = extends

    def load_env(self):

        # Check for CMAKE_PREFIX_PATH in manual cmake args
        self.manual_cmake_prefix_path = ''
        for cmake_arg in self.cmake_args:
            prefix_path_match = re.findall('-DCMAKE_PREFIX_PATH.*?=(.+)', cmake_arg)
            if len(prefix_path_match) > 0:
                self.manual_cmake_prefix_path = prefix_path_match[0]

        # Load and update mirror of 'sticky' CMake information
        if self.install:
            sticky_env = get_resultspace_environment(self.install_space_abs, quiet=True)
        else:
            sticky_env = get_resultspace_environment(self.devel_space_abs, quiet=True)

        self.cached_cmake_prefix_path = ''
        if 'CMAKE_PREFIX_PATH' in sticky_env:
            split_result_cmake_prefix_path = sticky_env.get('CMAKE_PREFIX_PATH', '').split(':')
            if len(split_result_cmake_prefix_path) > 1:
                self.cached_cmake_prefix_path = ':'.join(split_result_cmake_prefix_path[1:])

        # Either load an explicit environment or get it from the current environment
        self.env_cmake_prefix_path = ''
        if self.extend_path:
            extended_env = get_resultspace_environment(self.extend_path, quiet=False)
            self.env_cmake_prefix_path = extended_env.get('CMAKE_PREFIX_PATH', '')
            if not self.env_cmake_prefix_path:
                print(clr("@!@{rf}Error:@| Could not load environment from workspace: '%s', "
                          "target environment (env.sh) does not provide 'CMAKE_PREFIX_PATH'" % self.extend_path))
                print(extended_env)
                sys.exit(1)
        else:
            # Get the current CMAKE_PREFIX_PATH
            if 'CMAKE_PREFIX_PATH' in os.environ:
                split_result_cmake_prefix_path = os.environ['CMAKE_PREFIX_PATH'].split(':')
                if len(split_result_cmake_prefix_path) > 1 and (
                        (not self.install and split_result_cmake_prefix_path[0] == self.devel_space_abs) or
                        (self.install and split_result_cmake_prefix_path[0] == self.install_space_abs)):

                    self.env_cmake_prefix_path = ':'.join(split_result_cmake_prefix_path[1:])
                else:
                    self.env_cmake_prefix_path = os.environ.get('CMAKE_PREFIX_PATH', '').rstrip(':')

        # Add warning for empty extend path
        if (self.devel_layout == 'linked' and
            (self.extend_path is None and
             not self.cached_cmake_prefix_path and
             not self.env_cmake_prefix_path)):
            self.warnings += [clr(
                "Your workspace is not extending any other result space, but "
                "it is set to use a `linked` devel space layout. This "
                "requires the `catkin` CMake package in your source space "
                "in order to be built.")]

        # Add warnings based on conflicting CMAKE_PREFIX_PATH
        elif self.cached_cmake_prefix_path and self.extend_path:
            ep_not_in_lcpp = any([self.extend_path in p for p in self.cached_cmake_prefix_path.split(':')])
            if not ep_not_in_lcpp:
                self.warnings += [clr(
                    "Your workspace is configured to explicitly extend a "
                    "workspace which yields a CMAKE_PREFIX_PATH which is "
                    "different from the cached CMAKE_PREFIX_PATH used last time "
                    "this workspace was built.\\n\\n"
                    "If you want to use a different CMAKE_PREFIX_PATH you "
                    "should call @{yf}`catkin clean`@| to remove all "
                    "references to the previous CMAKE_PREFIX_PATH.\\n\\n"
                    "@{cf}Cached CMAKE_PREFIX_PATH:@|\\n\\t@{yf}%s@|\\n"
                    "@{cf}Other workspace to extend:@|\\n\\t@{yf}{_Context__extend_path}@|\\n"
                    "@{cf}Other workspace's CMAKE_PREFIX_PATH:@|\\n\\t@{yf}%s@|"
                    % (self.cached_cmake_prefix_path, self.env_cmake_prefix_path))]

        elif self.env_cmake_prefix_path and\
                self.cached_cmake_prefix_path and\
                self.env_cmake_prefix_path != self.cached_cmake_prefix_path:
            self.warnings += [clr(
                "Your current environment's CMAKE_PREFIX_PATH is different "
                "from the cached CMAKE_PREFIX_PATH used the last time this "
                "workspace was built.\\n\\n"
                "If you want to use a different CMAKE_PREFIX_PATH you should "
                "call @{yf}`catkin clean`@| to remove all references to "
                "the previous CMAKE_PREFIX_PATH.\\n\\n"
                "@{cf}Cached CMAKE_PREFIX_PATH:@|\\n\\t@{yf}%s@|\\n"
                "@{cf}Current CMAKE_PREFIX_PATH:@|\\n\\t@{yf}%s@|" %
                (self.cached_cmake_prefix_path, self.env_cmake_prefix_path))]

        # Check if prefix path is different from the environment prefix path
        if self.manual_cmake_prefix_path:
            self.cmake_prefix_path = self.manual_cmake_prefix_path
        elif self.cached_cmake_prefix_path:
            self.cmake_prefix_path = self.cached_cmake_prefix_path
        else:
            self.cmake_prefix_path = self.env_cmake_prefix_path

    def summary(self, notes=[]):
        # Add warnings (missing dirs in CMAKE_PREFIX_PATH, etc)
        summary_warnings = self.warnings
        if not self.initialized():
            summary_warnings += [clr(
                "Workspace `@{yf}{_Context__workspace}@|` is not yet "
                "initialized. Use the `catkin init` or run `catkin config "
                "--init`.")]
        if not self.source_space_exists():
            summary_warnings += [clr(
                "Source space `@{yf}{__source_space_abs}@|` does not yet exist.")]

        spaces_summary = []
        for space, space_dict in sorted(Context.SPACES.items()):
            spaces_summary.append(
                clr('@{cf}' + space_dict['space'] + ':@|' + ' ' * (18 - len(space_dict['space'])) +
                    '{' + space + '_missing} @{yf}{__' + space + '_space_abs}@|'))
        summary = [
            [
                clr("@{cf}Profile:@|                     @{yf}{profile}@|"),
                clr("@{cf}Extending:@|        {extend_mode} @{yf}{extend}@|"),
                clr("@{cf}Workspace:@|                   @{yf}{_Context__workspace}@|"),
            ],
            spaces_summary + [
                clr("@{cf}DESTDIR:@|           {destdir_missing} @{yf}{_Context__destdir}@|")
            ],
            [
                clr("@{cf}Devel Space Layout:@|          @{yf}{_Context__devel_layout}@|"),
                clr("@{cf}Install Space Layout:@|        @{yf}{install_layout}@|"),
            ],
            [
                clr("@{cf}Additional CMake Args:@|       @{yf}{cmake_args}@|"),
                clr("@{cf}Additional Make Args:@|        @{yf}{make_args}@|"),
                clr("@{cf}Additional catkin Make Args:@| @{yf}{catkin_make_args}@|"),
                clr("@{cf}Internal Make Job Server:@|    @{yf}{_Context__use_internal_make_jobserver}@|"),
                clr("@{cf}Cache Job Environments:@|      @{yf}{_Context__use_env_cache}@|"),
            ],
            [
                clr("@{cf}Buildlisted Packages:@|        @{yf}{buildlisted_packages}@|"),
                clr("@{cf}Skiplisted Packages:@|         @{yf}{skiplisted_packages}@|"),
            ]
        ]

        # Construct string for extend value
        if self.extend_path:
            extend_value = self.extend_path
            extend_mode = clr('@{gf}[explicit]@|')
        elif self.cached_cmake_prefix_path:
            extend_value = self.cmake_prefix_path
            extend_mode = clr('  @{gf}[cached]@|')
        elif (self.env_cmake_prefix_path and
                self.env_cmake_prefix_path != self.devel_space_abs and
                self.env_cmake_prefix_path != self.install_space_abs):
            extend_value = self.cmake_prefix_path
            extend_mode = clr('     @{gf}[env]@|')
        else:
            extend_value = 'None'
            extend_mode = clr('          ')

        def existence_str(path, used=True):
            if used:
                return clr(' @{gf}[exists]@|' if os.path.exists(path) else '@{rf}[missing]@|')
            else:
                return clr(' @{bf}[unused]@|')

        install_layout = 'None'
        if self.install:
            install_layout = 'merged' if not self.isolate_install else 'isolated'

        def quote(argument):
            # Distinguish in the printout if space separates two arguments or if we
            # print an argument with a space.
            # e.g. -DCMAKE_C_FLAGS="-g -O3" -DCMAKE_C_COMPILER=clang
            if ' ' in argument:
                if "=" in argument:
                    key, value = argument.split("=", 1)
                    if ' ' not in key:
                        return key + '="' + value + '"'
                return '"' + argument + '"'
            return argument

        subs = {
            'profile': self.profile,
            'extend_mode': extend_mode,
            'extend': extend_value,
            'install_layout': install_layout,
            'cmake_prefix_path': (self.cmake_prefix_path or ['Empty']),
            'cmake_args': ' '.join([quote(a) for a in self.cmake_args or ['None']]),
            'make_args': ' '.join(self.make_args + self.jobs_args or ['None']),
            'catkin_make_args': ', '.join(self.catkin_make_args or ['None']),
            'destdir_missing': existence_str(self.destdir, used=self.destdir),
            'buildlisted_packages': ' '.join(self.buildlist or ['None']),
            'skiplisted_packages': ' '.join(self.skiplist or ['None']),
        }
        for space, space_dict in sorted(Context.SPACES.items()):
            key_missing = '{}_missing'.format(space)
            space_abs = getattr(self, '{}_space_abs'.format(space))
            if hasattr(self, space):
                subs[key_missing] = existence_str(space_abs, used=getattr(self, space))
            else:
                subs[key_missing] = existence_str(space_abs)

        subs.update(**self.__dict__)
        # Get the width of the shell
        width = terminal_width()
        max_length = 0
        groups = []
        for group in summary:
            for index, line in enumerate(group):
                group[index] = line.format(**subs)
                max_length = min(width, max(max_length, len(remove_ansi_escape(group[index]))))
            groups.append("\n".join(group))
        divider = clr('@{pf}' + ('-' * max_length) + '@|')
        warning_divider = clr('@{rf}' + ('-' * max_length) + '@|')

        # Format warnings
        if len(summary_warnings) == 0:
            notes = [clr("@!@{cf}Workspace configuration appears valid.@|")] + notes
            warnings_joined = ''
        else:
            warnings_formatted = [
                printed_fill(clr('@!@{rf}WARNING:@| ') + sw.format(**subs), max_length)
                for sw in summary_warnings]
            warnings_joined = (
                "\n\n" + warning_divider + "\n" +
                ("\n" + warning_divider + "\n").join(warnings_formatted) +
                "\n" + warning_divider + "\n")

        return (divider + "\n" +
                ("\n" + divider + "\n").join(groups) + "\n" + divider + "\n" +
                (("\n\n".join(notes) + "\n" + divider) if notes else '') +
                warnings_joined)

    @property
    def workspace(self):
        return self.__workspace

    @workspace.setter
    def workspace(self, value):
        if self.__locked:
            raise RuntimeError("Setting of context members is not allowed while locked.")
        # Validate Workspace
        if not os.path.exists(value):
            raise ValueError("Workspace path '{0}' does not exist.".format(value))
        self.__workspace = os.path.realpath(os.path.abspath(value))

    @property
    def extend_path(self):
        return self.__extend_path

    @extend_path.setter
    def extend_path(self, value):
        if value is not None:
            if not os.path.isabs(value):
                value = os.path.join(self.workspace, value)
            # remove double or trailing slashes
            value = os.path.normpath(value)
            if not os.path.exists(value):
                raise ValueError("Resultspace path '{0}' does not exist.".format(value))
        self.__extend_path = value

    def initialized(self):
        """Check if this context is initialized."""
        return self.workspace == find_enclosing_workspace(self.workspace)

    @property
    def destdir(self):
        return self.__destdir

    @destdir.setter
    def destdir(self, value):
        if self.__locked:
            raise RuntimeError("Setting of context members is not allowed while locked.")
        self.__destdir = value

    @property
    def devel_layout(self):
        return self.__devel_layout

    @devel_layout.setter
    def devel_layout(self, value):
        if self.__locked:
            raise RuntimeError("Setting of context members is not allowed while locked.")
        self.__devel_layout = value

    @property
    def merge_devel(self):
        return self.devel_layout == 'merged'

    @property
    def link_devel(self):
        return self.devel_layout == 'linked'

    @property
    def isolate_devel(self):
        return self.devel_layout == 'isolated'

    @property
    def install(self):
        return self.__install

    @install.setter
    def install(self, value):
        if self.__locked:
            raise RuntimeError("Setting of context members is not allowed while locked.")
        self.__install = value

    @property
    def merge_install(self):
        return not self.__isolate_install

    @property
    def isolate_install(self):
        return self.__isolate_install

    @isolate_install.setter
    def isolate_install(self, value):
        if self.__locked:
            raise RuntimeError("Setting of context members is not allowed while locked.")
        self.__isolate_install = value

    @property
    def cmake_args(self):
        return self.__cmake_args

    @cmake_args.setter
    def cmake_args(self, value):
        if self.__locked:
            raise RuntimeError("Setting of context members is not allowed while locked.")
        self.__cmake_args = value

    @property
    def make_args(self):
        return self.__make_args

    @make_args.setter
    def make_args(self, value):
        if self.__locked:
            raise RuntimeError("Setting of context members is not allowed while locked.")
        self.__make_args = value

    @property
    def jobs_args(self):
        return self.__jobs_args

    @jobs_args.setter
    def jobs_args(self, value):
        if self.__locked:
            raise RuntimeError("Setting of context members is not allowed while locked.")
        self.__jobs_args = value

    @property
    def use_internal_make_jobserver(self):
        return self.__use_internal_make_jobserver

    @use_internal_make_jobserver.setter
    def use_internal_make_jobserver(self, value):
        if self.__locked:
            raise RuntimeError("Setting of context members is not allowed while locked.")
        self.__use_internal_make_jobserver = value

    @property
    def use_env_cache(self):
        return self.__use_env_cache

    @use_env_cache.setter
    def use_env_cache(self, value):
        if self.__locked:
            raise RuntimeError("Setting of context members is not allowed while locked.")
        self.__use_env_cache = value

    @property
    def catkin_make_args(self):
        return self.__catkin_make_args

    @catkin_make_args.setter
    def catkin_make_args(self, value):
        if self.__locked:
            raise RuntimeError("Setting of context members is not allowed while locked.")
        self.__catkin_make_args = value

    @property
    def packages(self):
        return self.__packages

    @packages.setter
    def packages(self, value):
        if self.__locked:
            raise RuntimeError("Setting of context members is not allowed while locked.")
        self.__packages = value

    @property
    def buildlist(self):
        return self.__buildlist

    @buildlist.setter
    def buildlist(self, value):
        self.__buildlist = value

    @property
    def skiplist(self):
        return self.__skiplist

    @skiplist.setter
    def skiplist(self, value):
        self.__skiplist = value

    @property
    def authors(self):
        return self.__authors

    @authors.setter
    def authors(self, value):
        self.__authors = value

    @property
    def maintainers(self):
        return self.__maintainers

    @maintainers.setter
    def maintainers(self, value):
        self.__maintainers = value

    @property
    def licenses(self):
        return self.__licenses

    @licenses.setter
    def licenses(self, value):
        self.__licenses = value

    @property
    def extends(self):
        return self.__extends

    @extends.setter
    def extends(self, value):
        self.__extends = value

    @property
    def private_devel_path(self):
        """The path to the hidden directory in the develspace that
        contains the symbolically-linked isolated develspaces."""
        return os.path.join(self.devel_space_abs, '.private')

    def package_private_devel_path(self, package):
        """The path to the linked devel space for a given package."""
        return os.path.join(self.private_devel_path, package.name)

    def package_source_space(self, package):
        """Get the source directory of a specific package."""
        for pkg_name, pkg in self.packages:
            if pkg_name == package.name:
                pkg_dir = os.path.dirname(pkg.filename)
                # Need to check if the pkg_dir is the source space as it can also be loaded from the metadata
                if os.path.commonpath([self.source_space_abs, pkg_dir]) == self.source_space_abs:
                    return pkg_dir

        return None

    def package_devel_space(self, package):
        """Get the devel directory for a specific package.
        This is the root of the FHS layout where products are generated.
        """
        if self.merge_devel:
            return self.devel_space_abs
        elif self.isolate_devel:
            return os.path.join(self.devel_space_abs, package.name)
        elif self.link_devel:
            return os.path.join(self.private_devel_path, package.name)
        else:
            raise ValueError('Unknown devel space layout: {}'.format(self.devel_layout))

    def package_install_space(self, package):
        """Get the install directory for a specific package.
        This is the root of the FHS layout where products are installed.
        """

        if self.merge_install:
            return self.install_space_abs
        elif self.isolate_install:
            return os.path.join(self.install_space_abs, package.name)
        else:
            raise ValueError('Unknown install space layout: {}'.format(self.devel_layout))

    def package_dest_path(self, package):
        """Get the intermediate destination into which a specific package is built."""

        if self.destdir is None:
            return self.package_final_path(package)
        else:
            return os.path.join(
                self.destdir,
                self.package_install_space(package).lstrip(os.sep))

    def package_final_path(self, package):
        """Get the final destination into which a specific package is deployed."""

        if self.install:
            return self.package_install_space(package)
        else:
            if self.link_devel:
                return self.devel_space_abs
            else:
                return self.package_devel_space(package)

    def metadata_path(self):
        """Get the path to the metadata directory for this profile."""
        profile_path, _ = metadata.get_paths(self.workspace, self.profile)
        return profile_path

    def package_metadata_path(self, package=None):
        """Get the workspace and profile-specific metadata path for a package"""
        profile_path, _ = metadata.get_paths(self.workspace, self.profile)
        if package is None:
            return os.path.join(profile_path, 'packages')
        return os.path.join(profile_path, 'packages', package.name)
