import abc
import copy
import datetime


def isodate_now():
    """Return current datetime in iso8601 format."""
    return "%s%s" % (datetime.datetime.utcnow().isoformat(), "Z")


class StepFailedError(Exception):
    """Exception indidating failure of a step."""


class Secret(object):
    """Class for storing sensitive values used as argument for Step class."""

    def __init__(self, val):
        """Init secret instance."""
        self.value = val

    def __str__(self):
        """Return *CENSORED* constant string."""
        return "*CENSORED*"


class StepState(object):
    """Enum-like class to store step state."""

    def __init__(self):
        """Init step state."""
        self._state = "ready"

    def get(self):
        """Return step state."""
        return self._state

    def set(self, state):
        """Set step state."""
        if state not in ("ready", "prep", "running", "finished", "failed", "error"):
            raise ValueError(
                "state has to be one of "
                "('ready', 'prep', 'running', "
                "'finished', 'failed', 'error')"
            )
        self._state = state


class StepResults(object):
    """Class to store results of step."""

    def __init__(self):
        """Init step results."""
        self.results = {}
        self.errors = {}

    def dump(self):
        """Return step results in json-compatible dict object."""
        return {"results": self.results, "errors": self.errors}

    def load(self, obj):
        """Load step results from dict object."""
        self.results = obj["results"]
        self.errors = obj["errors"]


class Step(object):
    """Base class for a Step.

    How to use this class: Few things are needed to implement custom step class.
    First, user needs to overwrite _run method which should do include all the code
    which is meant to be do desired step operation.
    In run method, user can access following instance attributes:
    `step_args`, `step_kwargs` - set when Step is initialized. These two variables
        are meant to hold data for the step. User needs to design step to work only
        with data which are json-compatible
    `shared_results` - shared dict-like object where step can store data for
        another steps or load data generated by previously ran steps.
    `external_resources` - resources which are needed for step to run but are not data.
        This can be for example logger, initialized client for external service and
        similar
    `results` - `StepResults` instance used to store data generated by the step.

    To provide detailed info about step status, step can store these details in _details
    attribute of the step instance. Details can contain anything json compatible. To
    set details to initial state, user needs to overwrite _init_details.
    Later, update_details method can be used to updating the details.
    User can overwrite `_pre_run` method to do any kind of 'lazy' preparation of data or
    set `skip` and `skip_reason` variables in the instance to prevent step from the
    execution.
    When there's data error or wrong data are provided, _run method can
    raise StepFailedError to set step to 'failed' state. Failed state indicates
    there's problem with data or configuration and step in this state cannot be
    executed again. If any other exception occurs and is not caught in _run, step is set
    to 'error' state and can be executed again. Only other two states indicating step is
    able to be executed are 'ready' and 'prep'. Ready state is set after initialization
    of the step instance. Step is switched to 'prep' state just before `_pre_run`
    is called. If step is in 'prep' state  `_pre_run` is not called again. After step
    finished the execution, and if there wasn't any error, `results` instance attribute
    is stored to `shared_results`.
    Last thing to do is to set NAME class attribute identify type of step
    """

    NAME = "AbstractSequenceStep"
    __metaclass__ = abc.ABCMeta

    def __init__(self, uid, step_args, step_kwargs, shared_results, external_resources=None):
        """Initilize the step.

        Args:
            uid: (str)
                An unique id for indentifying two steps of the same class
            step_args: (list)
                Arguments used for the step
            step_kwargs: (dict)
                Dictionary of a name arguments for the step
            shared_results: (dict-like object)
                Object to store shared data between steps
            external_resources: any-object
                Reference for external resources (in any form) which are constant and
                shouldn't be part of the step state or step data
        """
        self.step_args = step_args

        self.masked_args = [str(arg) if isinstance(arg, Secret) else arg for arg in step_args]
        self.masked_kwargs = dict(
            [
                (k, str(arg)) if isinstance(arg, Secret) else (k, arg)
                for k, arg in step_kwargs.items()
            ]
        )

        self.step_args = [arg.value if isinstance(arg, Secret) else arg for arg in step_args]
        self.step_kwargs = dict(
            [
                (k, arg.value) if isinstance(arg, Secret) else (k, arg)
                for k, arg in step_kwargs.items()
            ]
        )

        self._shared_results = shared_results
        self.external_resources = external_resources or {}
        self.uid = uid

        self.stats = {}
        self._details = {}
        self._details_initted = False
        self.skip = False
        self.skip_reason = ""
        self._state = StepState()
        self.results = StepResults()

    @property
    def fullname(self):
        """Full name of class instance."""
        return "%s:%s" % (self.NAME, self.uid)

    @property
    def state(self):
        """Property for retrieving class state."""
        return self._state.get()

    def set_state(self, state):
        """Set step state."""
        self._state.set(state)

    def run(self, on_update=None):
        """Run the step code.

        Step is expected to run when step state is ready, prep or error. For other
        state running the step code is omitted. If step is in ready state,
        _pre_run method is executed first and state is switched to prep.
        After prep phase finishes, and skip is not set to True, _run method containing
        all the code for running the step is executed.
        After _run finishes, step state is set to failed, error or finished. Statistics
        of step are update and potential results of step are stored in shared data object
        After every change of step state, on_update callback is called if set
        """
        _on_update = on_update if on_update else lambda: ()
        self._reset_stats()
        if self.state == "ready":
            self.stats["started"] = isodate_now()
            self.set_state("prep")
            self._pre_run()
            _on_update()
        try:
            if self.state not in ("prep", "error"):
                return
            if not self.skip:
                self.set_state("running")
                _on_update()
                self._run(on_update=_on_update)
        except StepFailedError:
            self.set_state("failed")
        except Exception:
            self.set_state("error")
            raise
        else:
            self.set_state("finished")
        finally:
            self._finish_stats()
            self._store_results()
            _on_update()

    def _pre_run(self):
        """Execute code needed before step run.

        In this method, all neccesary preparation of data can be done.
        It can be also used to determine if step should run or not by setting
        self.skip to True and providing self.skip_reason string with explanation.
        """
        pass

    def _reset_stats(self):
        self.stats = {
            "started": None,
            "finished": None,
            "skip": self.skip,
            "skip_reason": self.skip_reason,
            "skipped": None,
            "state": self.state,
        }

    def _finish_stats(self):
        self.stats["finished"] = isodate_now()
        self.stats["skipped"] = self.skip
        self.stats["skip"] = self.skip
        self.stats["skip_reason"] = self.skip_reason
        self.stats["state"] = self.state

    def _store_results(self):
        self._shared_results[self.fullname] = self.results

    @abc.abstractmethod
    def _run(self, on_update=None):  # pragma: no cover
        """Run code of the step.

        Method expects raise StepFailedError if step code fails due data error
        (incorrect configuration or missing/wrong data). That ends with step
        state set to failed.
        If error occurs due to uncaught exception in this method, step state
        will be set to error
        """
        raise NotImplementedError

    def _init_details(self):
        """Initialize step details.

        It's called internally before step details exported to json and before
        calling update_details if hasn't been called before.
        """
        pass

    def _update_details(self, details):  # pragma: no cover
        """Update step details (needs to be overwritten)."""
        pass

    def update_details(self, details_args):
        """Update step details, init them if needed.

        Also init details if those haven't been initilized before
        """
        if not self._details_initted:
            self._init_details()
            self._details_initted = True
        self._update_details(details_args)

    def dump(self):
        """Dump step data into json compatible complex dictionary."""
        if not self._details_initted:
            self._init_details()
            self._details_initted = True
        if not self.stats:
            self._reset_stats()

        return {
            "name": self.NAME,
            "step_args": self.masked_args,
            "step_kwargs": self.masked_kwargs,
            "uid": self.uid,
            "details": copy.deepcopy(self._details),
            "stats": self.stats.copy(),
            "results": self.results.dump(),
        }

    @classmethod
    def load(cls, obj, shared_results):
        """Load step data from dictionary produced by dump method."""
        ret = cls(obj["uid"], obj["step_args"], obj["step_kwargs"], shared_results)
        ret._details = obj["details"]
        ret.stats = obj["stats"]
        ret.skip = obj["stats"]["skip"]
        ret.results.load(obj["results"])
        ret._details_initted = True
        ret.skip_reason = obj["stats"]["skip_reason"]
        ret.set_state(obj["stats"]["state"])
        return ret


class Stepper(object):
    """Class which runs sequence of steps."""

    def __init__(self, step_map):
        """Initialize the stepper.

        Args:
            step_map: (mapping of "step-name": <step_class>)
                Mapping of step names to step classes. Used when loading stepper from
                json-compatible dict data
        """
        self.step_map = step_map
        self.steps = []
        self.shared_results = {}
        self.current_step = None

    def add_step(self, step):
        """Add step to step sequence."""
        self.steps.append(step)

    def dump(self):
        """Dump stepper state and shared_results to json compatible dict."""
        steps = []
        out = {"steps": steps, "shared_results": self.shared_results}
        for step in self.steps:
            steps.append(step.dump())
        return out

    def load(self, stepper_obj):
        """Load and initialize stepper from data produced by dump method."""
        self.steps = []
        self.shared_results = stepper_obj["shared_results"]
        for step_obj in stepper_obj["steps"]:
            step = self.step_map[step_obj["name"]].load(step_obj, self.shared_results)
            self.steps.append(step)

    def run(self, start_from=0, on_error=None, on_update=None):
        """Run the stepper sequence."""
        _on_error = on_error if on_error else lambda s: ()
        try:
            for step in self.steps[start_from:]:
                self.current_step = step
                step.run(on_update=on_update)
        except Exception:
            _on_error(self)
            raise
