import asyncio
import logging
import os
import zipfile
from pathlib import Path

import yaml

from toposort import toposort_flatten

from .client import client
from .constraints import parse as parse_constraints, parse_storage_constraint, parse_device_constraint
from .errors import JujuError

log = logging.getLogger(__name__)


class BundleHandler:
    """
    Handle bundles by using the API to translate bundle YAML into a plan of
    steps and then dispatching each of those using the API.
    """
    def __init__(self, model, trusted=False, forced=False):
        self.model = model
        self.trusted = trusted
        self.forced = forced

        self.charmstore = model.charmstore
        self.plan = []
        self.references = {}
        self._units_by_app = {}

        for unit_name, unit in model.units.items():
            app_units = self._units_by_app.setdefault(unit.application, [])
            app_units.append(unit_name)
        self.bundle_facade = client.BundleFacade.from_connection(
            model.connection())
        self.client_facade = client.ClientFacade.from_connection(
            model.connection())
        self.app_facade = client.ApplicationFacade.from_connection(
            model.connection())
        self.ann_facade = client.AnnotationsFacade.from_connection(
            model.connection())

        # This describes all the change types that the BundleHandler supports.
        change_type_cls = [AddApplicationChange,
                           AddCharmChange,
                           AddMachineChange,
                           AddRelationChange,
                           AddUnitChange,
                           CreateOfferChange,
                           ConsumeOfferChange,
                           ExposeChange,
                           ScaleChange,
                           SetAnnotationsChange]
        self.change_types = {}
        for change_cls in change_type_cls:
            self.change_types[change_cls.method()] = change_cls

    async def _validate_bundle(self, bundle):
        """Validate the bundle for known issues, raises an error if it
        encounters a known problem
        """
        apps_dict = bundle.get('applications', bundle.get('services', {}))
        for app_name in self.applications:
            app_dict = apps_dict[app_name]
            app_trusted = app_dict.get('trust')
            if (not self.trusted and not self.forced) and app_trusted:
                raise JujuError(
                    "Bundle cannot be deployed without trusting applications with your cloud credentials.\n"
                    "Please repeat the deploy command with the --trust argument if you consent to trust the following application\n"
                    " - {}\n".format(app_name)
                )
        return bundle

    async def _handle_local_charms(self, bundle, bundle_dir):
        """Search for references to local charms (i.e. filesystem paths)
        in the bundle. Upload the local charms to the model, and replace
        the filesystem paths with appropriate 'local:' paths in the bundle.

        Return the modified bundle.

        :param dict bundle: Bundle dictionary
        :return: Modified bundle dictionary

        """
        apps, args = [], []

        default_series = bundle.get('series')
        apps_dict = bundle.get('applications', bundle.get('services', {}))
        for app_name in self.applications:
            app_dict = apps_dict[app_name]
            charm_dir = app_dict['charm']
            try:
                charm_path = (bundle_dir / charm_dir).resolve()
                if not (charm_path.is_dir() or
                        (charm_path.is_file() and
                         charm_path.suffix in ('.charm', '.zip'))):
                    continue
                charm_dir = str(charm_path)
            except ValueError:
                pass
            series = (
                app_dict.get('series') or
                default_series or
                get_charm_series(charm_dir)
            )
            if not series:
                raise JujuError(
                    "Couldn't determine series for charm at {}. "
                    "Add a 'series' key to the bundle.".format(charm_dir))
            # Keep track of what we need to update. We keep a list of apps
            # that need to be updated, and a corresponding list of args
            # needed to update those apps.
            apps.append(app_name)
            args.append((charm_dir, series))

        if apps:
            # If we have apps to update, spawn all the coroutines concurrently
            # and wait for them to finish.
            charm_urls = await asyncio.gather(*[
                self.model.add_local_charm_dir(*params)
                for params in args
            ], loop=self.model.loop)
            # Update the 'charm:' entry for each app with the new 'local:' url.
            for app_name, charm_url in zip(apps, charm_urls):
                apps_dict[app_name]['charm'] = charm_url

        return bundle

    async def fetch_plan(self, entity_id):
        is_store_url = entity_id.startswith('cs:')
        is_local = False
        bundle_dir = None

        if not is_store_url and os.path.isfile(entity_id):
            bundle_yaml = Path(entity_id).read_text()
            is_local = True
            bundle_dir = Path(entity_id).parent
        elif not is_store_url and os.path.isdir(entity_id):
            bundle_yaml = (Path(entity_id) / "bundle.yaml").read_text()
            bundle_dir = Path(entity_id)
        else:
            bundle_yaml = await self.charmstore.files(entity_id,
                                                      filename='bundle.yaml',
                                                      read_file=True)
        self.bundle = yaml.safe_load(bundle_yaml)
        self.bundle = await self._validate_bundle(self.bundle)
        if is_local:
            self.bundle = await self._handle_local_charms(self.bundle, bundle_dir)

        self.plan = await self.bundle_facade.GetChanges(
            bundleurl=entity_id,
            yaml=yaml.dump(self.bundle))

        if self.plan.errors:
            raise JujuError(self.plan.errors)

    async def execute_plan(self):
        changes = ChangeSet(self.plan.changes)
        for step in changes.sorted():
            change_cls = self.change_types.get(step.method)
            if change_cls is None:
                raise NotImplementedError("unknown change type: {}".format(step.method))
            change = change_cls(step.id_, step.requires, step.args)
            log.info("Applying change: {}".format(change))
            self.references[step.id_] = await change.run(self)

    @property
    def applications(self):
        apps_dict = self.bundle.get('applications',
                                    self.bundle.get('services', {}))
        return list(apps_dict.keys())

    def resolveRelation(self, reference):
        parts = reference.split(":", maxsplit=1)
        application = self.resolve(parts[0])
        if len(parts) == 1:
            return application
        return "{}:{}".format(application, parts[1])

    def resolve(self, reference):
        if reference and reference.startswith('$'):
            ref = self.references[reference[1:]]
            if ref is not None:
                reference = ref
        return reference


def get_charm_series(path):
    """Inspects the charm directory at ``path`` and returns a default
    series from its metadata.yaml (the first item in the 'series' list).

    Returns None if no series can be determined.
    """
    try:
        if path.endswith('.charm'):
            md = "metadata.yaml in %s" % path
            with zipfile.ZipFile(path, 'r') as charm_file:
                data = yaml.safe_load(charm_file.read('metadata.yaml'))
        else:
            md = Path(path) / "metadata.yaml"
            if not md.exists():
                return None
            data = yaml.safe_load(md.open())
    except yaml.YAMLError as exc:
        if hasattr(exc, "problem_mark"):
            mark = exc.problem_mark
            log.error("Error parsing YAML file {}, line {}, column: {}".format(md, mark.line, mark.column))
        raise
    series = data.get('series')
    return series[0] if series else None


class ChangeSet:

    def __init__(self, changes):
        self.changes = changes

    # sorted does a topological sort of the changes
    def sorted(self):
        if len(self.changes) == 0:
            return []

        changes = {}
        for change in self.changes:
            changes[change.id_] = set(change.requires)
        sorted_changes = toposort_flatten(changes)
        results = []
        for change_id in sorted_changes:
            for change in self.changes:
                if change_id == change.id_:
                    results.append(change)
                    break
        return results


class ChangeInfo:
    _toPy = {}

    def __init__(self, change_id, requires):
        self.change_id = change_id
        self.requires = requires

    @classmethod
    def from_dict(cls, self, data):
        """from_dict converts a data bag into fields on a class instance.
        If a value is missing from the data, then None is assigned to the field
        instance value.
        """
        d = (data or {})
        for k, v in cls._toPy.items():
            if k in d:
                setattr(self, v, d[k])
            else:
                setattr(self, v, None)


class AddApplicationChange(ChangeInfo):
    _toPy = {'charm': 'charm',
             'series': 'series',
             'application': 'application',
             'options': 'options',
             'constraints': 'constraints',
             'storage': 'storage',
             'devices': 'devices',
             'endpoint-bindings': 'endpoint_bindings',
             'resources': 'resources',
             'num-units': 'num_units'}

    """AddApplicationChange holds a change for deploying a Juju application.

    :charm: URL of the charm to be used to deploy this application.
    :series: series of the application to be deployed if the charm
        default is not sufficient.
    :application: application name.
    :num_units: number of units required. For IAAS models, this will
        be 0 and separate AddUnitChanges will be used. For Kubernetes models,
        this will be used to scale the application.
    :options: holds application options.
    :constraints: optional application constraints.
    :storage: optional storage constraints.
    :devices: optional devices constraints.
    :endpoint_bindings: optional endpoint bindings
    :resources: identifies the revision to use for each resource of the
        application's charm.
    :local_resources: identifies the path to the local resource of the
        application's charm.
    """
    def __init__(self, change_id, requires, params=None):
        super(AddApplicationChange, self).__init__(change_id, requires)

        if isinstance(params, list):
            self.charm = params[0]
            self.series = params[1]
            self.application = params[2]
            self.options = params[3]
            self.constraints = params[4]
            self.storage = {k: parse_storage_constraint(v) for k, v in params[5].items()}
            if len(params) == 8:
                # Juju 2.4 and below only sends the endpoint bindings and resources
                self.endpoint_bindings = params[6]
                self.resources = params[7]
                self.devices = None
                self.num_units = None
            else:
                # Juju 2.5+ sends devices before endpoint bindings, as well as num_units
                # There might be placement but we need to ignore that.
                self.devices = {k: parse_device_constraint(v) for k, v in params[6].items()}
                self.endpoint_bindings = params[7]
                self.resources = params[8]
                self.num_units = params[9]

        elif isinstance(params, dict):
            AddApplicationChange.from_dict(self, params)
        else:
            raise Exception("unexpected params type")

    @staticmethod
    def method():
        """method returns an associated ID for the Juju API call.
        """
        return "deploy"

    async def run(self, context):
        """Executes a AddApplicationChange using the returned parameters from
        the API server.

        :param context: is used for any methods or properties required to
            perform a change.
        """
        # NB: this should really be handled by the controller when generating the
        # bundle change plan, and this short-term workaround may be missing some
        # aspects of the logic which the CLI client contains to handle edge cases.
        if self.application in context.model.applications:
            log.debug('Skipping %s; already in model', self.application)
            return self.application

        # resolve indirect references
        charm = context.resolve(self.charm)
        options = {}
        if self.options is not None:
            options = self.options
        if context.trusted:
            if context.model.info.agent_version < client.Number.from_json('2.4.0'):
                raise NotImplementedError("trusted is not supported on model version {}".format(context.model.info.agent_version))
            options["trust"] = "true"
        if not charm.startswith('local:'):
            resources = await context.model._add_store_resources(
                self.application, charm, overrides=self.resources)
        else:
            resources = {}
        await context.model._deploy(
            charm_url=charm,
            application=self.application,
            series=self.series,
            config=options,
            constraints=self.constraints,
            endpoint_bindings=self.endpoint_bindings,
            resources=resources,
            storage=self.storage,
            devices=self.devices,
            num_units=self.num_units,
        )
        return self.application

    def __str__(self):
        series = ""
        if self.series is not None and self.series != "":
            series = " on {}".format(self.series)
        units_info = ""
        if self.num_units is not None:
            plural = ""
            if self.num_units > 1:
                plural = "s"
            units_info = " with {num_units} unit{plural}".format(num_units=self.num_units,
                                                                 plural=plural)
        return "deploy application {application}{units_info}{series} using {charm}".format(application=self.application,
                                                                                           units_info=units_info,
                                                                                           series=series,
                                                                                           charm=self.charm)


class AddCharmChange(ChangeInfo):
    _toPy = {'charm': 'charm',
             'series': 'series',
             'channel': 'channel'}

    """AddCharmChange holds a change for adding a charm to the environment.

    :change_id: id of the change that will be used to identify the current
        change
    :requires: is a slice of dependencies that are required to happen.
    :params: holds the change parameters from the api response. Currently the
        params could either be a list or a dict. The later being the newer
        return results.

    Params holds the following values:
        :charm: URL of the charm to be added.
        :series: series of the charm to be added if the charm default is
           not sufficient.
        :channel: preferred channel for obtaining the charm.
    """
    def __init__(self, change_id, requires, params=None):
        super(AddCharmChange, self).__init__(change_id, requires)

        if isinstance(params, list):
            self.charm = params[0]
            self.series = params[1]
            if len(params) > 2 and params[2] != "":
                self.channel = params[2]
            else:
                self.channel = None
        elif isinstance(params, dict):
            AddCharmChange.from_dict(self, params)
        else:
            raise Exception("unexpected params type")

    @staticmethod
    def method():
        """method returns an associated ID for the Juju API call.
        """
        return "addCharm"

    async def run(self, context):
        """Executes a AddCharmChange using the returned parameters from
        the API server.

        :param context: is used for any methods or properties required to
            perform a change.
        """
        # We don't add local charms because they've already been added
        # by self._handle_local_charms
        if self.charm.startswith('local:'):
            return self.charm

        entity_id = await context.charmstore.entityId(self.charm, channel=self.channel)
        log.debug('Adding %s', entity_id)
        await context.client_facade.AddCharm(channel=self.channel, url=entity_id, force=False)
        return entity_id

    def __str__(self):
        series = ""
        channel = ""
        if self.series is not None and self.series != "":
            series = " for series {}".format(self.series)
        if self.channel is not None:
            channel = " from channel {}".format(self.channel)
        return "upload charm {charm}{series}{channel}".format(charm=self.charm,
                                                              series=series,
                                                              channel=channel)


class AddMachineChange(ChangeInfo):
    _toPy = {'series': 'series',
             'constraints': 'constraints',
             'container-type': 'container_type',
             'parent-id': 'parent_id'}

    """AddMachineChange holds a change for adding a machine or container.

    :change_id: id of the change that will be used to identify the current
        change
    :requires: is a slice of dependencies that are required to happen.
    :params: holds the change parameters from the api response. Currently the
        params could either be a list or a dict. The later being the newer
        return results.

    Params holds the following values:
        :series: optional machine OS series.
        :constraints: optional machine constraints.
        :container_type: optionally type of the container (for instance
            "lxc" or kvm"). It is not specified for top level machines.
        :parent_id: id of the parent machine.
    """
    def __init__(self, change_id, requires, params=None):
        super(AddMachineChange, self).__init__(change_id, requires)
        # this one is weird, as it returns a set of parameters inside a list.
        if isinstance(params, list):
            options = params[0] or {}
            self.series = options.get("series")
            self.constraints = options.get("constraints")
            self.container_type = options.get("containerType")
            self.parent_id = options.get("parentId")
        elif isinstance(params, dict):
            AddMachineChange.from_dict(self, params)
        else:
            raise Exception("unexpected params type")

    @staticmethod
    def method():
        """method returns an associated ID for the Juju API call.
        """
        return "addMachines"

    async def run(self, context):
        """Executes a AddMachineChange using the returned parameters from
        the API server.

        :param context: is used for any methods or properties required to
            perform a change.
        """
        # Fix up values, as necessary.
        params = {}
        if self.parent_id is not None:
            if self.parent_id.startswith('$addUnit'):
                unit = context.resolve(self.parent_id)[0]
                params['parent_id'] = unit.machine.entity_id
            else:
                params['parent_id'] = context.resolve(self.parent_id)

        params['constraints'] = parse_constraints(self.constraints)
        params['jobs'] = params.get('jobs', ['JobHostUnits'])
        params['series'] = self.series

        if self.container_type == 'lxc':
            log.warning('Juju 2.0 does not support lxc containers. '
                        'Converting containers to lxd.')
            params['container_type'] = 'lxd'
        else:
            params['container_type'] = self.container_type

        # Submit the request.
        params = client.AddMachineParams(**params)
        results = await context.client_facade.AddMachines(params=[params])
        error = results.machines[0].error
        if error:
            raise ValueError("Error adding machine: %s" % error.message)
        machine = results.machines[0].machine
        log.debug('Added new machine %s', machine)
        return machine

    def __str__(self):
        machine = "new machine"
        if self.container_type is not None and self.container_type != "":
            machine = "{container_type} container on {machine}".format(container_type=self.container_type,
                                                                       machine=machine)
        return "add {}".format(machine)


class AddRelationChange(ChangeInfo):
    _toPy = {'endpoint1': 'endpoint1',
             'endpoint2': 'endpoint2'}
    """AddRelationChange holds a change for adding a relation between two
    applications.

    :change_id: id of the change that will be used to identify the current
        change
    :requires: is a slice of dependencies that are required to happen.
    :params: holds the change parameters from the api response. Currently the
        params could either be a list or a dict. The later being the newer
        return results.

    Params holds the following values:
        Endpoint1 and Endpoint2 hold relation endpoints in the
        "application:interface" form, where the application is either a
        placeholder pointing to an application change or in the case of a model
        that already has this application deployed, the name of the
        application, and the interface is optional. Examples are
        "$deploy-42:web", "$deploy-42", "mysql:db".
    """
    def __init__(self, change_id, requires, params=None):
        super(AddRelationChange, self).__init__(change_id, requires)

        if isinstance(params, list):
            self.endpoint1 = params[0]
            self.endpoint2 = params[1]
        elif isinstance(params, dict):
            AddRelationChange.from_dict(self, params)
        else:
            raise Exception("unexpected params type")

    @staticmethod
    def method():
        """method returns an associated ID for the Juju API call.
        """
        return "addRelation"

    async def run(self, context):
        """Executes a AddRelationChange using the returned parameters from
        the API server.

        :param context: is used for any methods or properties required to
            perform a change.
        """
        ep1 = context.resolveRelation(self.endpoint1)
        ep2 = context.resolveRelation(self.endpoint2)

        # NB: this should really be handled by the controller when generating the
        # bundle change plan, and this short-term workaround may be missing some
        # aspects of the logic which the CLI client contains to handle edge cases.
        existing = [rel for rel in context.model.relations if rel.matches(ep1, ep2)]
        if existing:
            log.info('Skipping %s <-> %s; already related', ep1, ep2)
            return existing[0]

        log.info('Relating %s <-> %s', ep1, ep2)
        return await context.model.add_relation(ep1, ep2)

    def __str__(self):
        return "add relation {endpoint1} - {endpoint2}".format(endpoint1=self.endpoint1,
                                                               endpoint2=self.endpoint2)


class AddUnitChange(ChangeInfo):
    _toPy = {'application': 'application',
             'to': 'to'}
    """AddUnitChange holds a change for adding an application unit.

    :change_id: id of the change that will be used to identify the current
        change
    :requires: is a slice of dependencies that are required to happen.
    :params: holds the change parameters from the api response. Currently the
        params could either be a list or a dict. The later being the newer
        return results.

    Params holds the following values:
        :application: application placeholder name for which a unit is
            added.
        :to: optional location where to add the unit, as a placeholder
            pointing to another unit change or to a machine change.
    """
    def __init__(self, change_id, requires, params=None):
        super(AddUnitChange, self).__init__(change_id, requires)

        if isinstance(params, list):
            self.application = params[0]
            self.to = params[1]
        elif isinstance(params, dict):
            AddUnitChange.from_dict(self, params)
        else:
            raise Exception("unexpected params type")

    @staticmethod
    def method():
        """method returns an associated ID for the Juju API call.
        """
        return "addUnit"

    async def run(self, context):
        """Executes a AddUnitChange using the returned parameters from
        the API server.

        :param context: is used for any methods or properties required to
            perform a change.
        """
        application = context.resolve(self.application)
        placement = context.resolve(self.to)
        if context._units_by_app.get(application):
            # enough units for this application already exist;
            # claim one, and carry on
            # NB: this should probably honor placement, but the juju client
            # doesn't, so we're not bothering, either
            unit_name = context._units_by_app[application].pop()
            log.debug('Reusing unit %s for %s', unit_name, application)
            return context.model.units[unit_name]

        log.debug('Adding new unit for %s%s', application,
                  ' to %s' % placement if placement else '')
        return await context.model.applications[application].add_unit(
            count=1,
            to=placement,
        )

    def __str__(self):
        return "add {application} unit to {to}".format(application=self.application,
                                                       to=self.to)


class CreateOfferChange(ChangeInfo):
    _toPy = {'application': 'application',
             'endpoints': 'endpoints',
             'offer-name': 'offer_name'}
    """CreateOfferChange holds a change for creating a new application endpoint
    offer.

    :change_id: id of the change that will be used to identify the current
        change
    :requires: is a slice of dependencies that are required to happen.
    :params: holds the change parameters from the api response. Currently the
        params could either be a list or a dict. The later being the newer
        return results.

    Params holds the following values:
        :application: is the name of the application to create an offer for.
            added.
        :endpoint: is a list of application endpoint to expose as part of an
            offer.
        :offer_name: describes the offer name.
    """
    def __init__(self, change_id, requires, params=None):
        super(CreateOfferChange, self).__init__(change_id, requires)

        if isinstance(params, list):
            self.application = params[0]
            self.endpoints = params[1]
            self.offer_name = params[2]
        elif isinstance(params, dict):
            CreateOfferChange.from_dict(self, params)
        else:
            raise Exception("unexpected params type")

    @staticmethod
    def method():
        """method returns an associated ID for the Juju API call.
        """
        return "createOffer"

    async def run(self, context):
        """Executes a CreateOfferChange using the returned parameters from
        the API server.

        :param context: is used for any methods or properties required to
            perform a change.
        """
        application = context.resolve(self.application)
        for ep in self.endpoints:
            await context.model.create_offer(ep, offer_name=self.offer_name, application_name=application)

    def __str__(self):
        endpoints = ""
        if self.endpoints is not None:
            endpoints = ":{}".format(self.endpoints.join(","))
        return "create offer {offer_name} using {application}{endpoints}".format(offer_name=self.offer_name,
                                                                                 application=self.application,
                                                                                 endpoints=endpoints)


class ConsumeOfferChange(ChangeInfo):
    _toPy = {'url': 'url',
             'application-name': 'application_name'}
    """CreateOfferChange holds a change for consuming a offer.

    :change_id: id of the change that will be used to identify the current
        change
    :requires: is a slice of dependencies that are required to happen.
    :params: holds the change parameters from the api response. Currently the
        params could either be a list or a dict. The later being the newer
        return results.

    Params holds the following values:
        :url: contains the location of the offer
        :application_name: describes the application name on offer.
    """
    def __init__(self, change_id, requires, params=None):
        super(ConsumeOfferChange, self).__init__(change_id, requires)

        if isinstance(params, list):
            self.url = params[0]
            self.application_name = params[1]
        elif isinstance(params, dict):
            ConsumeOfferChange.from_dict(self, params)
        else:
            raise Exception("unexpected params type")

    @staticmethod
    def method():
        """method returns an associated ID for the Juju API call.
        """
        return "consumeOffer"

    async def run(self, context):
        """Executes a ConsumeOfferChange using the returned parameters from
        the API server.

        :param context: is used for any methods or properties required to
            perform a change.
        """
        application = context.resolve(self.application_name)
        local_name = await context.model.consume(self.url, application_alias=application)
        return local_name

    def __str__(self):
        return "consume offer {application_name} at {url}".format(application_name=self.application_name,
                                                                  url=self.url)


class ExposeChange(ChangeInfo):
    _toPy = {
        'application': 'application',
        'exposed-endpoints': 'exposed_endpoints',
    }
    """ExposeChange holds a change for exposing an application.

    :change_id: id of the change that will be used to identify the current
        change
    :requires: is a slice of dependencies that are required to happen.
    :params: holds the change parameters from the api response. Currently the
        params could either be a list or a dict. The later being the newer
        return results.

    Params holds the following values:
        :application: placeholder name of the application that must be
            exposed.
        :exposed_endpoints: a an optional dictionary where keys are endpoint
            names and values are dicts that specify the space names and CIDRs
            that should be able to access the port ranges that the application
            has opened for each endpoint.
    """
    def __init__(self, change_id, requires, params=None):
        super(ExposeChange, self).__init__(change_id, requires)

        if isinstance(params, list):
            self.application = params[0]
        elif isinstance(params, dict):
            ExposeChange.from_dict(self, params)
        else:
            raise Exception("unexpected params type")

    @staticmethod
    def method():
        """method returns an associated ID for the Juju API call.
        """
        return "expose"

    async def run(self, context):
        """Executes a ExposeChange using the returned parameters from
        the API server.

        :param context: is used for any methods or properties required to
            perform a change.
        """
        application = context.resolve(self.application)
        log.info('Exposing %s', application)
        return await context.model.applications[application].expose(self.exposed_endpoints)

    def __str__(self):
        return "expose {application}".format(application=self.application)


class ScaleChange(ChangeInfo):
    _toPy = {'application': 'application',
             'scale': 'scale'}
    """
    ScaleChange holds a change for scaling an application.

    :change_id: id of the change that will be used to identify the current
        change
    :requires: is a slice of dependencies that are required to happen.
    :params: holds the change parameters from the api response. Currently the
        params could either be a list or a dict. The later being the newer
        return results.

    Params holds the following values:
        :application: placeholder name of the application to be scaled.
        :scale: is the new scale value to use.
    """
    def __init__(self, change_id, requires, params=None):
        super(ScaleChange, self).__init__(change_id, requires)

        if isinstance(params, list):
            self.application = params[0]
            self.scale = params[1]
        elif isinstance(params, dict):
            ScaleChange.from_dict(self, params)
        else:
            raise Exception("unexpected params type")

    @staticmethod
    def method():
        """method returns an associated ID for the Juju API call.
        """
        return "scale"

    async def run(self, context):
        """Executes a ScaleChange using the returned parameters from
        the API server.

        :param context: is used for any methods or properties required to
            perform a change.
        """
        application = context.resolve(self.application)
        return await context.model.applications[application].scale(scale=self.scale)

    def __str__(self):
        return "scale {application} to {scale} units".format(application=self.application,
                                                             scale=self.scale)


class SetAnnotationsChange(ChangeInfo):
    _toPy = {'id': 'id',
             'entity-type': 'entity_type',
             'annotations': 'annotations'}
    """SetAnnotationsChange holds a change for setting application and machine
    annotations.

    :change_id: id of the change that will be used to identify the current
        change
    :requires: is a slice of dependencies that are required to happen.
    :params: holds the change parameters from the api response. Currently the
        params could either be a list or a dict. The later being the newer
        return results.

    Params holds the following values:
        :id: is the placeholder for the application or machine change
            corresponding to the entity to be annotated.
        :entity_type: type of the entity, "application" or "machine".
        :ennotations: annotations as key/value pairs.
    """
    def __init__(self, change_id, requires, params=None):
        super(SetAnnotationsChange, self).__init__(change_id, requires)

        if isinstance(params, list):
            self.id = params[0]
            self.entity_type = params[1]
            self.annotations = params[2]
        elif isinstance(params, dict):
            SetAnnotationsChange.from_dict(self, params)
        else:
            raise Exception("unexpected params type")

    @staticmethod
    def method():
        """method returns an associated ID for the Juju API call.
        """
        return "setAnnotations"

    async def run(self, context):
        """Executes a SetAnnotationsChange using the returned parameters from
        the API server.

        :param context: is used for any methods or properties required to
            perform a change.
        """
        entity_id = context.resolve(self.id)
        try:
            entity = context.model.state.get_entity(self.entity_type, entity_id)
        except KeyError:
            entity = await context.model._wait_for_new(self.entity_type, entity_id)
        return await entity.set_annotations(self.annotations)

    def __str__(self):
        return "set annotations for {id}".format(id=self.id)
