# Copyright 2014 Google Inc. All Rights Reserved.

# 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.


"""Measurements for OpenHTF.

Measurements in OpenHTF are used to represent values collected during a Test.
They can be numeric or string values, and can be configured such that the
OpenHTF framework will automatically check them against Pass/Fail criteria.
Measurements should not be used for large binary blobs, which are instead best
stored as Attachments (see attachments.py).

Measurements are described by the measurements.Measurement class.  Essentially,
the Measurement class is used by test authors to declare measurements by name,
and to optionally provide unit, type, and validation information.  Measurements
are attached to Test Phases using the @measurements.measures() decorator.

When measurements are output by the OpenHTF framework, the Measurement objects
are serialized into the 'measurements' field on the PhaseRecord, which contain
both descriptor fields, outcome (PASS/FAIL/UNSET), and the values themselves.

Validation of undimensioned measurements happens when they are set, so that
users of the HTTP API can see PASS/FAIL outcome on those measurements
immediately after they are set.  Multidimensional measurements, however,
don't usually make sense to validate until all data is available, so they
instead enter a PARTIALLY_SET outcome state until the end of the test phase,
at which point they are validated and become with PASS or FAIL.  Note that
validators of dimensioned measurements are only called at the end of the phase
if at least one value was set in the multidimensional measurement, otherwise it
remains UNSET, so that outcome fields for all measurements may be PASS, FAIL,
or UNSET.

# TODO(madsci): Make validators.py example.
See examples/validators.py for some examples on how to define and use custom
measurement validators.

Examples:

  @measurements.measures(
      measurements.Measurement(
          'number_widgets').InRange(5, 10).doc(
          '''This phase parameter tracks the number of widgets.'''))
  @measurements.measures(
      *(measurements.Measurement('level_%s' % lvl)
        for lvl in ('none', 'some', 'all')))
  def WidgetTestPhase(test):
    test.measurements.number_widgets = 5
    test.measurements.level_none = 10

"""


import collections
import logging

from enum import Enum

import mutablerecords

import openhtf
from openhtf import util
from openhtf.util import validators
from openhtf.util import units

_LOG = logging.getLogger(__name__)


class InvalidDimensionsError(Exception):
  """Raised when there is a problem with measurement dimensions."""


class InvalidMeasurementType(Exception):
  """Raised when an unexpected measurement type is given."""


class MeasurementNotSetError(Exception):
  """Raised when a measurement is accessed that hasn't been set."""


class NotAMeasurementError(Exception):
  """Raised if an invalid measurement name is accessed."""


class DuplicateNameError(Exception):
  """An exception which occurs when a measurement name collision occurs."""


# Only multidimensional measurements can be 'PARTIALLY_SET', and can never be in
# that state after their respective phase has completed (they must transition to
# either PASS or FAIL at that point).
Outcome = Enum('Outcome', ['PASS', 'FAIL', 'UNSET', 'PARTIALLY_SET'])


class Measurement(  # pylint: disable=no-init
    mutablerecords.Record(
        'Measurement', ['name'],
        {'units': None, 'dimensions': None, 'docstring': None,
         '_notification_cb': None, 'validators': list, 'outcome': Outcome.UNSET,
         'measured_value': None})):
  """Record encapsulating descriptive data for a measurement.

  This record includes an _asdict() method so it can be easily output.  Output
  is as you would expect, a dict mapping non-None fields to their values
  (validators are stringified with str()).

  Attributes:
    name: Name of the measurement.
    docstring: Optional string describing this measurement.
    units: UOM code of the units for the measurement being taken.
    dimensions: Tuple of UOM codes for units of dimensions.
    validators: List of callable validator objects to perform pass/fail checks.
    outcome: One of the Outcome() enumeration values, starting at UNSET.
    measured_value: An instance of MeasuredValue or DimensionedMeasuredValue
      containing the value(s) of this Measurement that have been set, if any.
  """

  def __init__(self, name, **kwargs):
    super(Measurement, self).__init__(name, **kwargs)
    self._initialize_value()

  def _initialize_value(self):
    if self.measured_value and self.measured_value.is_value_set:
      raise ValueError('Cannot update a Measurement once a value is set.')

    if self.dimensions:
      self.measured_value = DimensionedMeasuredValue(
          self.name, len(self.dimensions))
    else:
      self.measured_value = MeasuredValue(self.name)

  def __setattr__(self, attr, value):
    super(Measurement, self).__setattr__(attr, value)
    # When dimensions changes, we may need to update our measured_value type.
    if attr == 'dimensions':
      self._initialize_value()

  def __setstate__(self, state):
    """Set this record's state during unpickling.

    This override is necessary to ensure that the the _initialize_value check
    is skipped during unpickling.
    """
    dimensions = state.pop('dimensions')
    super(Measurement, self).__setstate__(state)
    object.__setattr__(self, 'dimensions', dimensions)

  def set_notification_callback(self, notification_cb):