"""Context and settings for :mod:`message_ix_models` code."""
import logging
import os
from copy import deepcopy
from pathlib import Path
from typing import List, Tuple
from warnings import warn
import ixmp
import message_ix
from click import BadOptionUsage
from message_ix_models.util import (
    load_package_data,
    package_data_path,
    private_data_path,
)
log = logging.getLogger(__name__)
#: List of Context instances, from first created to last.
_CONTEXTS: List["Context"] = []
[docs]class Context(dict):
    """Context and settings for :mod:`message_ix_models` code."""
    # NB the docs contain a table of settings
[docs]    @classmethod
    def get_instance(cls, index=0) -> "Context":
        """Return a Context instance; by default, the first created.
        Parameters
        ----------
        index : int, optional
            Index of the Context instance to return, e.g. ``-1`` for the most recently
            created.
        """
        return _CONTEXTS[index] 
[docs]    @classmethod
    def only(cls) -> "Context":
        """Return the only :class:`.Context` instance.
        Raises
        ------
        IndexError
            If there is more than one instance.
        """
        if len(_CONTEXTS) > 1:
            raise IndexError(f"ambiguous: {len(_CONTEXTS)} Context instances")
        return _CONTEXTS[0] 
    def __init__(self, *args, **kwargs):
        if len(_CONTEXTS) == 0:
            log.info("Create root Context")
        # Store any keyword arguments
        super().__init__(*args, **kwargs)
        # Default paths for local data
        default_local_data = Path(
            os.environ.get("MESSAGE_LOCAL_DATA", None)
            or ixmp.config.values.get("message local data", None)
            or Path.cwd()
        ).resolve()
        for key, value in (
            ("platform_info", dict()),
            ("scenario_info", dict()),
            ("local_data", default_local_data),
        ):
            self.setdefault(key, value)
        # Store a reference for get_instance()
        _CONTEXTS.append(self)
    # Attribute access
    def __setattr__(self, name, value):
        self[name] = value
    def __getattr__(self, name):
        try:
            return self[name]
        except KeyError:
            raise AttributeError(name) from None
    def __deepcopy__(self, memo):
        mp = self.pop("_mp", None)
        result = deepcopy(super(), memo)
        if mp is not None:
            self._mp = mp
        _CONTEXTS.append(result)
        return result
[docs]    def delete(self):
        """Hide the current Context from future :meth:`.get_instance` calls."""
        index = _CONTEXTS.index(self)
        if index > 0:
            _CONTEXTS.pop(index)
        else:
            log.warning("Won't delete the only Context instance")
        self.close_db() 
[docs]    def clone_to_dest(self) -> Tuple[message_ix.Scenario, ixmp.Platform]:
        """Return a scenario based on the ``--dest`` command-line option.
        Returns
        -------
        Scenario
            To prevent the scenario from being garbage collected. Keep a reference to
            its Platform:
            .. code-block: python
               s = context.clone_to_dest()
               mp = s.platform
        See also
        --------
        create_res
        """
        if "dest_scenario" not in self:
            # No information on the destination; try to parse a URL, storing the keys
            # dest_platform and dest_scenario.
            self.handle_cli_args(
                url=self["dest"], _store_as=("dest_platform", "dest_scenario")
            )
        try:
            # Get the base scenario, e.g. from the --url CLI argument
            scenario_base = self.get_scenario()
            # By default, clone to the same platform
            mp_dest = scenario_base.platform
            try:
                # Get information about a destination platform
                info = self["dest_platform"]
            except KeyError:
                pass  # dest_platform not set; use the same as scenario_base
            else:  # pragma: no cover
                # Not tested; current test fixtures make it difficult to create *two*
                # temporary platforms simultaneously
                if info["name"] != mp_dest.name:
                    # Different platform
                    mp_dest = ixmp.Platform(**info)
        except Exception:
            log.info("No base scenario given")
            # Create a bare RES to be the base scenario
            from message_ix_models.model.bare import create_res
            # Create on the destination platform
            c = deepcopy(self)
            c.platform_info.update(self.get("dest_platform", {}))
            scenario_base = create_res(c)
            # Clone to the same platform
            mp_dest = scenario_base.platform
        # Clone
        log.info(f"Clone to {repr(self.dest_scenario)}")
        return scenario_base.clone(platform=mp_dest, **self["dest_scenario"]) 
    def close_db(self):
        try:
            mp = self.pop("_mp")
            mp.close_db()
        except KeyError:
            pass
[docs]    def get_cache_path(self, *parts) -> Path:
        """Return a path to a local cache file."""
        base = self.get("cache_path", self.local_data.joinpath("cache"))
        result = base.joinpath(*parts)
        # Ensure the directory exists
        result.parent.mkdir(parents=True, exist_ok=True)
        return result 
[docs]    def get_local_path(self, *parts, suffix=None):
        """Return a path under ``local_data``."""
        result = self.local_data.joinpath(*parts)
        return result.with_suffix(suffix or result.suffix) 
[docs]    def get_platform(self, reload=False) -> ixmp.Platform:
        """Return a :class:`ixmp.Platform` from :attr:`platform_info`.
        When used through the CLI, :attr:`platform_info` is a 'base' platform
        as indicated by the --url or --platform  options.
        If a Platform has previously been instantiated with
        :meth:`get_platform`, the same object is returned unless `reload=True`.
        """
        if not reload:
            # Return an existing Platform, if any
            try:
                return self["_mp"]
            except KeyError:
                pass
        # Close any existing Platform, e.g. to reload it
        try:
            self["_mp"].close_db()
            del self["_mp"]
        except KeyError:
            pass
        # Create a Platform
        self["_mp"] = ixmp.Platform(**self.platform_info)
        return self["_mp"] 
[docs]    def get_scenario(self) -> message_ix.Scenario:
        """Return a :class:`message_ix.Scenario` from :attr:`scenario_info`.
        When used through the CLI, :attr:`scenario_info` is a ‘base’ scenario for an
        operation, indicated by the ``--url`` or ``--platform/--model/--scenario``
        options.
        """
        return message_ix.Scenario(self.get_platform(), **self.scenario_info) 
[docs]    def handle_cli_args(
        self,
        url=None,
        platform=None,
        model_name=None,
        scenario_name=None,
        version=None,
        local_data=None,
        _store_as=("platform_info", "scenario_info"),
    ):
        """Handle command-line arguments.
        May update the :attr:`data_path`, :attr:`platform_info`, :attr:`scenario_info`,
        and/or :attr:`url` settings.
        """
        # Store the path to command-specific data and metadata
        if local_data:
            self.local_data = local_data
        # References to the Context settings to be updated
        platform_info = self.setdefault(_store_as[0], dict())
        scenario_info = self.setdefault(_store_as[1], dict())
        # Store information for the target Platform
        if url:
            if platform or model_name or scenario_name or version:
                raise BadOptionUsage(
                    "--platform --model --scenario and/or --version",
                    " redundant with --url",
                )
            self.url = url
            urlinfo = ixmp.utils.parse_url(url)
            platform_info.update(urlinfo[0])
            scenario_info.update(urlinfo[1])
        elif platform:
            platform_info["name"] = platform
        # Store information about the target Scenario
        if model_name:
            scenario_info["model"] = model_name
        if scenario_name:
            scenario_info["scenario"] = scenario_name
        if version:
            scenario_info["version"] = version 
[docs]    def use_defaults(self, settings):
        """Update from `settings`."""
        for setting, info in settings.items():
            if setting not in self:
                log.info(f"Use default {setting}={info[0]}")
            value = self.setdefault(setting, info[0])
            if value not in info:
                raise ValueError(f"{setting} must be in {info}; got {value}") 
    # Deprecated methods
[docs]    def get_config_file(self, *parts, ext="yaml") -> Path:
        """Return a path under :attr:`metadata_path`.
        The suffix ".{ext}" is added; defaulting to ".yaml".
        .. deprecated:: 2021.2.28
           Use :func:`.package_data_path` instead.
           Will be removed on or after 2021-05-28.
        """
        # TODO remove on or after 2021-05-28
        warn(
            "Context.get_config_file(). Instead use:\n"
            "from message_ix_models import package_data_path",
            DeprecationWarning,
            stacklevel=2,
        )
        return package_data_path(*parts).with_suffix(f".{ext}") 
[docs]    def get_path(self, *parts) -> Path:  # pragma: no cover  (needs message_data)
        """Return a path under :attr:`message_data_path` by joining *parts*.
        *parts* may include directory names, or a filename with extension.
        .. deprecated:: 2021.2.28
           Use :func:`.private_data_path` instead.
           Will be removed on or after 2021-05-28.
        """
        # TODO remove on or after 2021-05-28
        warn(
            "Context.get_path(). Instead use: \n"
            "from message_ix_models import private_data_path",
            DeprecationWarning,
            stacklevel=2,
        )
        return private_data_path(*parts) 
[docs]    def load_config(self, *parts, suffix=None):
        """Load configuration from :mod:`message_ix_models`.
        .. deprecated:: 2021.2.28
           Use :func:`.load_package_data` instead.
           Will be removed on or after 2021-05-28.
        """
        # TODO remove on or after 2021-05-28
        warn(
            "Context.load_config(). Instead use:\n"
            "from message_ix_models.util import load_package_data",
            DeprecationWarning,
            stacklevel=2,
        )
        result = load_package_data(*parts, suffix=suffix)
        self[" ".join(parts)] = result
        return result 
    @property
    def units(self):
        """Access the unit registry.
        .. deprecated:: 2021.2.28
           Instead, use:
           .. code-block:: python
              from iam_units import registry
           Will be removed on or after 2021-05-28.
        """
        # TODO remove on or after 2021-05-28
        warn(
            "Context.units attribute. Instead use:\nfrom iam_units import registry",
            DeprecationWarning,
            stacklevel=2,
        )
        from iam_units import registry
        return registry