# -*- coding: utf-8 -*-
#
# Copyright (C) 2012 The Python Software Foundation.
# See LICENSE.txt and CONTRIBUTORS.txt.
#
"""Implementation of the Metadata for Python packages PEPs.

Supports all metadata formats (1.0, 1.1, 1.2, 1.3/2.1 and 2.2).
"""
from __future__ import unicode_literals

import codecs
from email import message_from_file
import json
import logging
import re


from . import DistlibException, __version__
from .compat import StringIO, string_types, text_type
from .markers import interpret
from .util import extract_by_key, get_extras
from .version import get_scheme, PEP440_VERSION_RE

logger = logging.getLogger(__name__)


class MetadataMissingError(DistlibException):
    """A required metadata is missing"""


class MetadataConflictError(DistlibException):
    """Attempt to read or write metadata fields that are conflictual."""


class MetadataUnrecognizedVersionError(DistlibException):
    """Unknown metadata version number."""


class MetadataInvalidError(DistlibException):
    """A metadata value is invalid"""


# public API of this module
__all__ = ["Metadata", "PKG_INFO_ENCODING", "PKG_INFO_PREFERRED_VERSION"]

# Encoding used for the PKG-INFO files
PKG_INFO_ENCODING = "utf-8"

# preferred version. Hopefully will be changed
# to 1.2 once PEP 345 is supported everywhere
PKG_INFO_PREFERRED_VERSION = "1.1"

_LINE_PREFIX_1_2 = re.compile("\n       \\|")
_LINE_PREFIX_PRE_1_2 = re.compile("\n        ")
_241_FIELDS = (
    "Metadata-Version",
    "Name",
    "Version",
    "Platform",
    "Summary",
    "Description",
    "Keywords",
    "Home-page",
    "Author",
    "Author-email",
    "License",
)

_314_FIELDS = (
    "Metadata-Version",
    "Name",
    "Version",
    "Platform",
    "Supported-Platform",
    "Summary",
    "Description",
    "Keywords",
    "Home-page",
    "Author",
    "Author-email",
    "License",
    "Classifier",
    "Download-URL",
    "Obsoletes",
    "Provides",
    "Requires",
)

_314_MARKERS = ("Obsoletes", "Provides", "Requires", "Classifier", "Download-URL")

_345_FIELDS = (
    "Metadata-Version",
    "Name",
    "Version",
    "Platform",
    "Supported-Platform",
    "Summary",
    "Description",
    "Keywords",
    "Home-page",
    "Author",
    "Author-email",
    "Maintainer",
    "Maintainer-email",
    "License",
    "Classifier",
    "Download-URL",
    "Obsoletes-Dist",
    "Project-URL",
    "Provides-Dist",
    "Requires-Dist",
    "Requires-Python",
    "Requires-External",
)

_345_MARKERS = (
    "Provides-Dist",
    "Requires-Dist",
    "Requires-Python",
    "Obsoletes-Dist",
    "Requires-External",
    "Maintainer",
    "Maintainer-email",
    "Project-URL",
)

_426_FIELDS = (
    "Metadata-Version",
    "Name",
    "Version",
    "Platform",
    "Supported-Platform",
    "Summary",
    "Description",
    "Keywords",
    "Home-page",
    "Author",
    "Author-email",
    "Maintainer",
    "Maintainer-email",
    "License",
    "Classifier",
    "Download-URL",
    "Obsoletes-Dist",
    "Project-URL",
    "Provides-Dist",
    "Requires-Dist",
    "Requires-Python",
    "Requires-External",
    "Private-Version",
    "Obsoleted-By",
    "Setup-Requires-Dist",
    "Extension",
    "Provides-Extra",
)

_426_MARKERS = (
    "Private-Version",
    "Provides-Extra",
    "Obsoleted-By",
    "Setup-Requires-Dist",
    "Extension",
)

# See issue #106: Sometimes 'Requires' and 'Provides' occur wrongly in
# the metadata. Include them in the tuple literal below to allow them
# (for now).
# Ditto for Obsoletes - see issue #140.
_566_FIELDS = _426_FIELDS + (
    "Description-Content-Type",
    "Requires",
    "Provides",
    "Obsoletes",
)

_566_MARKERS = ("Description-Content-Type",)

_643_MARKERS = ("Dynamic", "License-File")

_643_FIELDS = _566_FIELDS + _643_MARKERS

_ALL_FIELDS = set()
_ALL_FIELDS.update(_241_FIELDS)
_ALL_FIELDS.update(_314_FIELDS)
_ALL_FIELDS.update(_345_FIELDS)
_ALL_FIELDS.update(_426_FIELDS)
_ALL_FIELDS.update(_566_FIELDS)
_ALL_FIELDS.update(_643_FIELDS)

EXTRA_RE = re.compile(r"""extra\s*==\s*("([^"]+)"|'([^']+)')""")


def _version2fieldlist(version):
    if version == "1.0":
        return _241_FIELDS
    elif version == "1.1":
        return _314_FIELDS
    elif version == "1.2":
        return _345_FIELDS
    elif version in ("1.3", "2.1"):
        # avoid adding field names if already there
        return _345_FIELDS + tuple(f for f in _566_FIELDS if f not in _345_FIELDS)
    elif version == "2.0":
        raise ValueError("Metadata 2.0 is withdrawn and not supported")
        # return _426_FIELDS
    elif version == "2.2":
        return _643_FIELDS
    raise MetadataUnrecognizedVersionError(version)


def _best_version(fields):
    """Detect the best version depending on the fields used."""

    def _has_marker(keys, markers):
        for marker in markers:
            if marker in keys:
                return True
        return False

    keys = []
    for key, value in fields.items():
        if value in ([], "UNKNOWN", None):
            continue
        keys.append(key)

    possible_versions = ["1.0", "1.1", "1.2", "1.3", "2.1", "2.2"]  # 2.0 removed

    # first let's try to see if a field is not part of one of the version
    for key in keys:
        if key not in _241_FIELDS and "1.0" in possible_versions:
            possible_versions.remove("1.0")
            logger.debug("Removed 1.0 due to %s", key)
        if key not in _314_FIELDS and "1.1" in possible_versions:
            possible_versions.remove("1.1")
            logger.debug("Removed 1.1 due to %s", key)
        if key not in _345_FIELDS and "1.2" in possible_versions:
            possible_versions.remove("1.2")
            logger.debug("Removed 1.2 due to %s", key)
        if key not in _566_FIELDS and "1.3" in possible_versions:
            possible_versions.remove("1.3")
            logger.debug("Removed 1.3 due to %s", key)
        if key not in _566_FIELDS and "2.1" in possible_versions:
            if key != "Description":  # In 2.1, description allowed after headers
                possible_versions.remove("2.1")
                logger.debug("Removed 2.1 due to %s", key)
        if key not in _643_FIELDS and "2.2" in possible_versions:
            possible_versions.remove("2.2")
            logger.debug("Removed 2.2 due to %s", key)
        # if key not in _426_FIELDS and '2.0' in possible_versions:
        # possible_versions.remove('2.0')
        # logger.debug('Removed 2.0 due to %s', key)

    # possible_version contains qualified versions
    if len(possible_versions) == 1:
        return possible_versions[0]  # found !
    elif len(possible_versions) == 0:
        logger.debug("Out of options - unknown metadata set: %s", fields)
        raise MetadataConflictError("Unknown metadata set")

    # let's see if one unique marker is found
    is_1_1 = "1.1" in possible_versions and _has_marker(keys, _314_MARKERS)
    is_1_2 = "1.2" in possible_versions and _has_marker(keys, _345_MARKERS)
    is_2_1 = "2.1" in possible_versions and _has_marker(keys, _566_MARKERS)
    # is_2_0 = '2.0' in possible_versions and _has_marker(keys, _426_MARKERS)
    is_2_2 = "2.2" in possible_versions and _has_marker(keys, _643_MARKERS)
    if int(is_1_1) + int(is_1_2) + int(is_2_1) + int(is_2_2) > 1:
        raise MetadataConflictError("You used incompatible 1.1/1.2/2.1/2.2 fields")

    # we have the choice, 1.0, or 1.2, 2.1 or 2.2
    #   - 1.0 has a broken Summary field but works with all tools
    #   - 1.1 is to avoid
    #   - 1.2 fixes Summary but has little adoption
    #   - 2.1 adds more features
    #   - 2.2 is the latest
    if not is_1_1 and not is_1_2 and not is_2_1 and not is_2_2:
        # we couldn't find any specific marker
        if PKG_INFO_PREFERRED_VERSION in possible_versions:
            return PKG_INFO_PREFERRED_VERSION
    if is_1_1:
        return "1.1"
    if is_1_2:
        return "1.2"
    if is_2_1:
        return "2.1"
    # if is_2_2:
    # return '2.2'

    return "2.2"


# This follows the rules about transforming keys as described in
# https://www.python.org/dev/peps/pep-0566/#id17
_ATTR2FIELD = {name.lower().replace("-", "_"): name for name in _ALL_FIELDS}
_FIELD2ATTR = {field: attr for attr, field in _ATTR2FIELD.items()}

_PREDICATE_FIELDS = ("Requires-Dist", "Obsoletes-Dist", "Provides-Dist")
_VERSIONS_FIELDS = ("Requires-Python",)
_VERSION_FIELDS = ("Version",)
_LISTFIELDS = (
    "Platform",
    "Classifier",
    "Obsoletes",
    "Requires",
    "Provides",
    "Obsoletes-Dist",
    "Provides-Dist",
    "Requires-Dist",
    "Requires-External",
    "Project-URL",
    "Supported-Platform",
    "Setup-Requires-Dist",
    "Provides-Extra",
    "Extension",
    "License-File",
)
_LISTTUPLEFIELDS = ("Project-URL",)

_ELEMENTSFIELD = ("Keywords",)

_UNICODEFIELDS = ("Author", "Maintainer", "Summary", "Description")

_MISSING = object()

_FILESAFE = re.compile("[^A-Za-z0-9.]+")


def _get_name_and_version(name, version, for_filename=False):
    """Return the distribution name with version.

    If for_filename is true, return a filename-escaped form."""
    if for_filename:
        # For both name and version any runs of non-alphanumeric or '.'
        # characters are replaced with a single '-'.  Additionally any
        # spaces in the version string become '.'
        name = _FILESAFE.sub("-", name)
        version = _FILESAFE.sub("-", version.replace(" ", "."))
    return "%s-%s" % (name, version)


class LegacyMetadata(object):
    """The legacy metadata of a release.

    Supports versions 1.0, 1.1, 1.2, 2.0 and 1.3/2.1 (auto-detected). You can
    instantiate the class with one of these arguments (or none):
    - *path*, the path to a metadata file
    - *fileobj* give a file-like object with metadata as content
    - *mapping* is a dict-like object
    - *scheme* is a version scheme name
    """

    # TODO document the mapping API and UNKNOWN default key

    def __init__(self, path=None, fileobj=None, mapping=None, scheme="default"):
        if [path, fileobj, mapping].count(None) < 2:
            raise TypeError("path, fileobj and mapping are exclusive")
        self._fields = {}
        self.requires_files = []
        self._dependencies = None
        self.scheme = scheme
        if path is not None:
            self.read(path)
        elif fileobj is not None:
            self.read_file(fileobj)
        elif mapping is not None:
            self.update(mapping)
            self.set_metadata_version()

    def set_metadata_version(self):
        self._fields["Metadata-Version"] = _best_version(self._fields)

    def _write_field(self, fileobj, name, value):
        fileobj.write("%s: %s\n" % (name, value))

    def __getitem__(self, name):
        return self.get(name)

    def __setitem__(self, name, value):
        return self.set(name, value)

    def __delitem__(self, name):
        field_name = self._convert_name(name)
        try:
            del self._fields[field_name]
        except KeyError:
            raise KeyError(name)

    def __contains__(self, name):
        return name in self._fields or self._convert_name(name) in self._fields

    def _convert_name(self, name):
        if name in _ALL_FIELDS:
            return name
        name = name.replace("-", "_").lower()
        return _ATTR2FIELD.get(name, name)

    def _default_value(self, name):
        if name in _LISTFIELDS or name in _ELEMENTSFIELD:
            return []
        return "UNKNOWN"

    def _remove_line_prefix(self, value):
        if self.metadata_version in ("1.0", "1.1"):
            return _LINE_PREFIX_PRE_1_2.sub("\n", value)
        else:
            return _LINE_PREFIX_1_2.sub("\n", value)

    def __getattr__(self, name):
        if name in _ATTR2FIELD:
            return self[name]
        raise AttributeError(name)

    #
    # Public API
    #

    #    dependencies = property(_get_dependencies, _set_dependencies)

    def get_fullname(self, filesafe=False):
        """Return the distribution name with version.

        If filesafe is true, return a filename-escaped form."""
        return _get_name_and_version(self["Name"], self["Version"], filesafe)

    def is_field(self, name):
        """return True if name is a valid metadata key"""
        name = self._convert_name(name)
        return name in _ALL_FIELDS

    def is_multi_field(self, name):
        name = self._convert_name(name)
        return name in _LISTFIELDS

    def read(self, filepath):
        """Read the metadata values from a file path."""
        fp = codecs.open(filepath, "r", encoding="utf-8")
        try:
            self.read_file(fp)
        finally:
            fp.close()

    def read_file(self, fileob):
        """Read the metadata values from a file object."""
        msg = message_from_file(fileob)
        self._fields["Metadata-Version"] = msg["metadata-version"]

        # When reading, get all the fields we can
        for field in _ALL_FIELDS:
            if field not in msg:
                continue
            if field in _LISTFIELDS:
                # we can have multiple lines
                values = msg.get_all(field)
                if field in _LISTTUPLEFIELDS and values is not None:
                    values = [tuple(value.split(",")) for value in values]
                self.set(field, values)
            else:
                # single line
                value = msg[field]
                if value is not None and value != "UNKNOWN":
                    self.set(field, value)

        # PEP 566 specifies that the body be used for the description, if
        # available
        body = msg.get_payload()
        self["Description"] = body if body else self["Description"]
        # logger.debug('Attempting to set metadata for %s', self)
        # self.set_metadata_version()

    def write(self, filepath, skip_unknown=False):
        """Write the metadata fields to filepath."""
        fp = codecs.open(filepath, "w", encoding="utf-8")
        try:
            self.write_file(fp, skip_unknown)
        finally:
            fp.close()

    def write_file(self, fileobject, skip_unknown=False):
        """Write the PKG-INFO format data to a file object."""
        self.set_metadata_version()

        for field in _version2fieldlist(self["Metadata-Version"]):
            values = self.get(field)
            if skip_unknown and values in ("UNKNOWN", [], ["UNKNOWN"]):
                continue
            if field in _ELEMENTSFIELD:
                self._write_field(fileobject, field, ",".join(values))
                continue
            if field not in _LISTFIELDS:
                if field == "Description":
                    if self.metadata_version in ("1.0", "1.1"):
                        values = values.replace("\n", "\n        ")
                    else:
                        values = values.replace("\n", "\n       |")
                values = [values]

            if field in _LISTTUPLEFIELDS:
                values = [",".join(value) for value in values]

            for value in values:
                self._write_field(fileobject, field, value)

    def update(self, other=None, **kwargs):
        """Set metadata values from the given iterable `other` and kwargs.

        Behavior is like `dict.update`: If `other` has a ``keys`` method,
        they are looped over and ``self[key]`` is assigned ``other[key]``.
        Else, ``other`` is an iterable of ``(key, value)`` iterables.

        Keys that don't match a metadata field or that have an empty value are
        dropped.
        """

        def _set(key, value):
            if key in _ATTR2FIELD and value:
                self.set(self._convert_name(key), value)

        if not other:
            # other is None or empty container
            pass
        elif hasattr(other, "keys"):
            for k in other.keys():
                _set(k, other[k])
        else:
            for k, v in other:
                _set(k, v)

        if kwargs:
            for k, v in kwargs.items():
                _set(k, v)

    def set(self, name, value):
        """Control then set a metadata field."""
        name = self._convert_name(name)

        if (name in _ELEMENTSFIELD or name == "Platform") and not isinstance(
            value, (list, tuple)
        ):
            if isinstance(value, string_types):
                value = [v.strip() for v in value.split(",")]
            else:
                value = []
        elif name in _LISTFIELDS and not isinstance(value, (list, tuple)):
            if isinstance(value, string_types):
                value = [value]
            else:
                value = []

        if logger.isEnabledFor(logging.WARNING):
            project_name = self["Name"]

            scheme = get_scheme(self.scheme)
            if name in _PREDICATE_FIELDS and value is not None:
                for v in value:
                    # check that the values are valid
                    if not scheme.is_valid_matcher(v.split(";")[0]):
                        logger.warning(
                            "'%s': '%s' is not valid (field '%s')",
                            project_name,
                            v,
                            name,
                        )
            # FIXME this rejects UNKNOWN, is that right?
            elif name in _VERSIONS_FIELDS and value is not None:
                if not scheme.is_valid_constraint_list(value):
                    logger.warning(
                        "'%s': '%s' is not a valid version (field '%s')",
                        project_name,
                        value,
                        name,
                    )
            elif name in _VERSION_FIELDS and value is not None:
                if not scheme.is_valid_version(value):
                    logger.warning(
                        "'%s': '%s' is not a valid version (field '%s')",
                        project_name,
                        value,
                        name,
                    )

        if name in _UNICODEFIELDS:
            if name == "Description":
                value = self._remove_line_prefix(value)

        self._fields[name] = value

    def get(self, name, default=_MISSING):
        """Get a metadata field."""
        name = self._convert_name(name)
        if name not in self._fields:
            if default is _MISSING:
                default = self._default_value(name)
            return default
        if name in _UNICODEFIELDS:
            value = self._fields[name]
            return value
        elif name in _LISTFIELDS:
            value = self._fields[name]
            if value is None:
                return []
            res = []
            for val in value:
                if name not in _LISTTUPLEFIELDS:
                    res.append(val)
                else:
                    # That's for Project-URL
                    res.append((val[0], val[1]))
            return res

        elif name in _ELEMENTSFIELD:
            value = self._fields[name]
            if isinstance(value, string_types):
                return value.split(",")
        return self._fields[name]

    def check(self, strict=False):
        """Check if the metadata is compliant. If strict is True then raise if
        no Name or Version are provided"""
        self.set_metadata_version()

        # XXX should check the versions (if the file was loaded)
        missing, warnings = [], []

        for attr in ("Name", "Version"):  # required by PEP 345
            if attr not in self:
                missing.append(attr)

        if strict and missing != []:
            msg = "missing required metadata: %s" % ", ".join(missing)
            raise MetadataMissingError(msg)

        for attr in ("Home-page", "Author"):
            if attr not in self:
                missing.append(attr)

        # checking metadata 1.2 (XXX needs to check 1.1, 1.0)
        if self["Metadata-Version"] != "1.2":
            return missing, warnings

        scheme = get_scheme(self.scheme)

        def are_valid_constraints(value):
            for v in value:
                if not scheme.is_valid_matcher(v.split(";")[0]):
                    return False
            return True

        for fields, controller in (
            (_PREDICATE_FIELDS, are_valid_constraints),
            (_VERSIONS_FIELDS, scheme.is_valid_constraint_list),
            (_VERSION_FIELDS, scheme.is_valid_version),
        ):
            for field in fields:
                value = self.get(field, None)
                if value is not None and not controller(value):
                    warnings.append("Wrong value for '%s': %s" % (field, value))

        return missing, warnings

    def todict(self, skip_missing=False):
        """Return fields as a dict.

        Field names will be converted to use the underscore-lowercase style
        instead of hyphen-mixed case (i.e. home_page instead of Home-page).
        This is as per https://www.python.org/dev/peps/pep-0566/#id17.
        """
        self.set_metadata_version()

        fields = _version2fieldlist(self["Metadata-Version"])

        data = {}

        for field_name in fields:
            if not skip_missing or field_name in self._fields:
                key = _FIELD2ATTR[field_name]
                if key != "project_url":
                    data[key] = self[field_name]
                else:
                    data[key] = [",".join(u) for u in self[field_name]]

        return data

    def add_requirements(self, requirements):
        if self["Metadata-Version"] == "1.1":
            # we can't have 1.1 metadata *and* Setuptools requires
            for field in ("Obsoletes", "Requires", "Provides"):
                if field in self:
                    del self[field]
        self["Requires-Dist"] += requirements

    # Mapping API
    # TODO could add iter* variants

    def keys(self):
        return list(_version2fieldlist(self["Metadata-Version"]))

    def __iter__(self):
        for key in self.keys():
            yield key

    def values(self):
        return [self[key] for key in self.keys()]

    def items(self):
        return [(key, self[key]) for key in self.keys()]

    def __repr__(self):
        return "<%s %s %s>" % (self.__class__.__name__, self.name, self.version)


METADATA_FILENAME = "pydist.json"
WHEEL_METADATA_FILENAME = "metadata.json"
LEGACY_METADATA_FILENAME = "METADATA"


class Metadata(object):
    """
    The metadata of a release. This implementation uses 2.1
    metadata where possible. If not possible, it wraps a LegacyMetadata
    instance which handles the key-value metadata format.
    """

    METADATA_VERSION_MATCHER = re.compile(r"^\d+(\.\d+)*$")

    NAME_MATCHER = re.compile("^[0-9A-Z]([0-9A-Z_.-]*[0-9A-Z])?$", re.I)

    FIELDNAME_MATCHER = re.compile("^[A-Z]([0-9A-Z-]*[0-9A-Z])?$", re.I)

    VERSION_MATCHER = PEP440_VERSION_RE

    SUMMARY_MATCHER = re.compile(".{1,2047}")

    METADATA_VERSION = "2.0"

    GENERATOR = "distlib (%s)" % __version__

    MANDATORY_KEYS = {
        "name": (),
        "version": (),
        "summary": ("legacy",),
    }

    INDEX_KEYS = (
        "name version license summary description author "
        "author_email keywords platform home_page classifiers "
        "download_url"
    )

    DEPENDENCY_KEYS = (
        "extras run_requires test_requires build_requires "
        "dev_requires provides meta_requires obsoleted_by "
        "supports_environments"
    )

    SYNTAX_VALIDATORS = {
        "metadata_version": (METADATA_VERSION_MATCHER, ()),
        "name": (NAME_MATCHER, ("legacy",)),
        "version": (VERSION_MATCHER, ("legacy",)),
        "summary": (SUMMARY_MATCHER, ("legacy",)),
        "dynamic": (FIELDNAME_MATCHER, ("legacy",)),
    }

    __slots__ = ("_legacy", "_data", "scheme")

    def __init__(self, path=None, fileobj=None, mapping=None, scheme="default"):
        if [path, fileobj, mapping].count(None) < 2:
            raise TypeError("path, fileobj and mapping are exclusive")
        self._legacy = None
        self._data = None
        self.scheme = scheme
        # import pdb; pdb.set_trace()
        if mapping is not None:
            try:
                self._validate_mapping(mapping, scheme)
                self._data = mapping
            except MetadataUnrecognizedVersionError:
                self._legacy = LegacyMetadata(mapping=mapping, scheme=scheme)
                self.validate()
        else:
            data = None
            if path:
                with open(path, "rb") as f:
                    data = f.read()
            elif fileobj:
                data = fileobj.read()
            if data is None:
                # Initialised with no args - to be added
                self._data = {
                    "metadata_version": self.METADATA_VERSION,
                    "generator": self.GENERATOR,
                }
            else:
                if not isinstance(data, text_type):
                    data = data.decode("utf-8")
                try:
                    self._data = json.loads(data)
                    self._validate_mapping(self._data, scheme)
                except ValueError:
                    # Note: MetadataUnrecognizedVersionError does not
                    # inherit from ValueError (it's a DistlibException,
                    # which should not inherit from ValueError).
                    # The ValueError comes from the json.load - if that
                    # succeeds and we get a validation error, we want
                    # that to propagate
                    self._legacy = LegacyMetadata(fileobj=StringIO(data), scheme=scheme)
                    self.validate()

    common_keys = set(("name", "version", "license", "keywords", "summary"))

    none_list = (None, list)
    none_dict = (None, dict)

    mapped_keys = {
        "run_requires": ("Requires-Dist", list),
        "build_requires": ("Setup-Requires-Dist", list),
        "dev_requires": none_list,
        "test_requires": none_list,
        "meta_requires": none_list,
        "extras": ("Provides-Extra", list),
        "modules": none_list,
        "namespaces": none_list,
        "exports": none_dict,
        "commands": none_dict,
        "classifiers": ("Classifier", list),
        "source_url": ("Download-URL", None),
        "metadata_version": ("Metadata-Version", None),
    }

    del none_list, none_dict

    def __getattribute__(self, key):
        common = object.__getattribute__(self, "common_keys")
        mapped = object.__getattribute__(self, "mapped_keys")
        if key in mapped:
            lk, maker = mapped[key]
            if self._legacy:
                if lk is None:
                    result = None if maker is None else maker()
                else:
                    result = self._legacy.get(lk)
            else:
                value = None if maker is None else maker()
                if key not in (
                    "commands",
                    "exports",
                    "modules",
                    "namespaces",
                    "classifiers",
                ):
                    result = self._data.get(key, value)
                else:
                    # special cases for PEP 459
                    sentinel = object()
                    result = sentinel
                    d = self._data.get("extensions")
                    if d:
                        if key == "commands":
                            result = d.get("python.commands", value)
                        elif key == "classifiers":
                            d = d.get("python.details")
                            if d:
                                result = d.get(key, value)
                        else:
                            d = d.get("python.exports")
                            if not d:
                                d = self._data.get("python.exports")
                            if d:
                                result = d.get(key, value)
                    if result is sentinel:
                        result = value
        elif key not in common:
            result = object.__getattribute__(self, key)
        elif self._legacy:
            result = self._legacy.get(key)
        else:
            result = self._data.get(key)
        return result

    def _validate_value(self, key, value, scheme=None):
        if key in self.SYNTAX_VALIDATORS:
            pattern, exclusions = self.SYNTAX_VALIDATORS[key]
            if (scheme or self.scheme) not in exclusions:
                m = pattern.match(value)
                if not m:
                    raise MetadataInvalidError(
                        "'%s' is an invalid value for "
                        "the '%s' property" % (value, key)
                    )

    def __setattr__(self, key, value):
        self._validate_value(key, value)
        common = object.__getattribute__(self, "common_keys")
        mapped = object.__getattribute__(self, "mapped_keys")
        if key in mapped:
            lk, _ = mapped[key]
            if self._legacy:
                if lk is None:
                    raise NotImplementedError
                self._legacy[lk] = value
            elif key not in (
                "commands",
                "exports",
                "modules",
                "namespaces",
                "classifiers",
            ):
                self._data[key] = value
            else:
                # special cases for PEP 459
                d = self._data.setdefault("extensions", {})
                if key == "commands":
                    d["python.commands"] = value
                elif key == "classifiers":
                    d = d.setdefault("python.details", {})
                    d[key] = value
                else:
                    d = d.setdefault("python.exports", {})
                    d[key] = value
        elif key not in common:
            object.__setattr__(self, key, value)
        else:
            if key == "keywords":
                if isinstance(value, string_types):
                    value = value.strip()
                    if value:
                        value = value.split()
                    else:
                        value = []
            if self._legacy:
                self._legacy[key] = value
            else:
                self._data[key] = value

    @property
    def name_and_version(self):
        return _get_name_and_version(self.name, self.version, True)

    @property
    def provides(self):
        if self._legacy:
            result = self._legacy["Provides-Dist"]
        else:
            result = self._data.setdefault("provides", [])
        s = "%s (%s)" % (self.name, self.version)
        if s not in result:
            result.append(s)
        return result

    @provides.setter
    def provides(self, value):
        if self._legacy:
            self._legacy["Provides-Dist"] = value
        else:
            self._data["provides"] = value

    def get_requirements(self, reqts, extras=None, env=None):
        """
        Base method to get dependencies, given a set of extras
        to satisfy and an optional environment context.
        :param reqts: A list of sometimes-wanted dependencies,
                      perhaps dependent on extras and environment.
        :param extras: A list of optional components being requested.
        :param env: An optional environment for marker evaluation.
        """
        if self._legacy:
            result = reqts
        else:
            result = []
            extras = get_extras(extras or [], self.extras)
            for d in reqts:
                if "extra" not in d and "environment" not in d:
                    # unconditional
                    include = True
                else:
                    if "extra" not in d:
                        # Not extra-dependent - only environment-dependent
                        include = True
                    else:
                        include = d.get("extra") in extras
                    if include:
                        # Not excluded because of extras, check environment
                        marker = d.get("environment")
                        if marker:
                            include = interpret(marker, env)
                if include:
                    result.extend(d["requires"])
            for key in ("build", "dev", "test"):
                e = ":%s:" % key
                if e in extras:
                    extras.remove(e)
                    # A recursive call, but it should terminate since 'test'
                    # has been removed from the extras
                    reqts = self._data.get("%s_requires" % key, [])
                    result.extend(self.get_requirements(reqts, extras=extras, env=env))
        return result

    @property
    def dictionary(self):
        if self._legacy:
            return self._from_legacy()
        return self._data

    @property
    def dependencies(self):
        if self._legacy:
            raise NotImplementedError
        else:
            return extract_by_key(self._data, self.DEPENDENCY_KEYS)

    @dependencies.setter
    def dependencies(self, value):
        if self._legacy:
            raise NotImplementedError
        else:
            self._data.update(value)

    def _validate_mapping(self, mapping, scheme):
        if mapping.get("metadata_version") != self.METADATA_VERSION:
            raise MetadataUnrecognizedVersionError()
        missing = []
        for key, exclusions in self.MANDATORY_KEYS.items():
            if key not in mapping:
                if scheme not in exclusions:
                    missing.append(key)
        if missing:
            msg = "Missing metadata items: %s" % ", ".join(missing)
            raise MetadataMissingError(msg)
        for k, v in mapping.items():
            self._validate_value(k, v, scheme)

    def validate(self):
        if self._legacy:
            missing, warnings = self._legacy.check(True)
            if missing or warnings:
                logger.warning("Metadata: missing: %s, warnings: %s", missing, warnings)
        else:
            self._validate_mapping(self._data, self.scheme)

    def todict(self):
        if self._legacy:
            return self._legacy.todict(True)
        else:
            result = extract_by_key(self._data, self.INDEX_KEYS)
            return result

    def _from_legacy(self):
        assert self._legacy and not self._data
        result = {
            "metadata_version": self.METADATA_VERSION,
            "generator": self.GENERATOR,
        }
        lmd = self._legacy.todict(True)  # skip missing ones
        for k in ("name", "version", "license", "summary", "description", "classifier"):
            if k in lmd:
                if k == "classifier":
                    nk = "classifiers"
                else:
                    nk = k
                result[nk] = lmd[k]
        kw = lmd.get("Keywords", [])
        if kw == [""]:
            kw = []
        result["keywords"] = kw
        keys = (
            ("requires_dist", "run_requires"),
            ("setup_requires_dist", "build_requires"),
        )
        for ok, nk in keys:
            if ok in lmd and lmd[ok]:
                result[nk] = [{"requires": lmd[ok]}]
        result["provides"] = self.provides
        author = {}
        maintainer = {}
        return result

    LEGACY_MAPPING = {
        "name": "Name",
        "version": "Version",
        ("extensions", "python.details", "license"): "License",
        "summary": "Summary",
        "description": "Description",
        ("extensions", "python.project", "project_urls", "Home"): "Home-page",
        ("extensions", "python.project", "contacts", 0, "name"): "Author",
        ("extensions", "python.project", "contacts", 0, "email"): "Author-email",
        "source_url": "Download-URL",
        ("extensions", "python.details", "classifiers"): "Classifier",
    }

    def _to_legacy(self):
        def process_entries(entries):
            reqts = set()
            for e in entries:
                extra = e.get("extra")
                env = e.get("environment")
                rlist = e["requires"]
                for r in rlist:
                    if not env and not extra:
                        reqts.add(r)
                    else:
                        marker = ""
                        if extra:
                            marker = 'extra == "%s"' % extra
                        if env:
                            if marker:
                                marker = "(%s) and %s" % (env, marker)
                            else:
                                marker = env
                        reqts.add(";".join((r, marker)))
            return reqts

        assert self._data and not self._legacy
        result = LegacyMetadata()
        nmd = self._data
        # import pdb; pdb.set_trace()
        for nk, ok in self.LEGACY_MAPPING.items():
            if not isinstance(nk, tuple):
                if nk in nmd:
                    result[ok] = nmd[nk]
            else:
                d = nmd
                found = True
                for k in nk:
                    try:
                        d = d[k]
                    except (KeyError, IndexError):
                        found = False
                        break
                if found:
                    result[ok] = d
        r1 = process_entries(self.run_requires + self.meta_requires)
        r2 = process_entries(self.build_requires + self.dev_requires)
        if self.extras:
            result["Provides-Extra"] = sorted(self.extras)
        result["Requires-Dist"] = sorted(r1)
        result["Setup-Requires-Dist"] = sorted(r2)
        # TODO: any other fields wanted
        return result

    def write(self, path=None, fileobj=None, legacy=False, skip_unknown=True):
        if [path, fileobj].count(None) != 1:
            raise ValueError("Exactly one of path and fileobj is needed")
        self.validate()
        if legacy:
            if self._legacy:
                legacy_md = self._legacy
            else:
                legacy_md = self._to_legacy()
            if path:
                legacy_md.write(path, skip_unknown=skip_unknown)
            else:
                legacy_md.write_file(fileobj, skip_unknown=skip_unknown)
        else:
            if self._legacy:
                d = self._from_legacy()
            else:
                d = self._data
            if fileobj:
                json.dump(d, fileobj, ensure_ascii=True, indent=2, sort_keys=True)
            else:
                with codecs.open(path, "w", "utf-8") as f:
                    json.dump(d, f, ensure_ascii=True, indent=2, sort_keys=True)

    def add_requirements(self, requirements):
        if self._legacy:
            self._legacy.add_requirements(requirements)
        else:
            run_requires = self._data.setdefault("run_requires", [])
            always = None
            for entry in run_requires:
                if "environment" not in entry and "extra" not in entry:
                    always = entry
                    break
            if always is None:
                always = {"requires": requirements}
                run_requires.insert(0, always)
            else:
                rset = set(always["requires"]) | set(requirements)
                always["requires"] = sorted(rset)

    def __repr__(self):
        name = self.name or "(no name)"
        version = self.version or "no version"
        return "<%s %s %s (%s)>" % (
            self.__class__.__name__,
            self.metadata_version,
            name,
            version,
        )
