# *****************************************************************************
#
# This program is free software; you can redistribute it and/or modify it under
# the terms of the GNU General Public License as published by the Free Software
# Foundation; either version 2 of the License, or (at your option) any later
# version.
#
# This program is distributed in the hope that it will be useful, but WITHOUT
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
# FOR A PARTICULAR PURPOSE.  See the GNU General Public License for more
# details.
#
# You should have received a copy of the GNU General Public License along with
# this program; if not, write to the Free Software Foundation, Inc.,
# 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA
#
# Module authors:
#   Markus Zolliker <markus.zolliker@psi.ch>
#
# *****************************************************************************


from frappy.core import Drivable, Parameter
from frappy.datatypes import StatusType, Enum
from frappy.states import StateMachine, Stop, Retry, Finish, Start, HasStates, status_code
from frappy.lib import generalConfig


class LoggerStub:
    def info(self, fmt, *args):
        print(fmt % args)

    def debug(self, fmt, *args):
        pass

    warning = exception = error = info
    handlers = []


def rise(state):
    state.step += 1
    if state.init:
        state.status = 'rise'
    state.level += 1
    if state.level > 3:
        return turn
    return Retry


def turn(state):
    state.step += 1
    if state.init:
        state.status = 'turn'
    state.direction += 1
    if state.direction > 3:
        return fall
    return Retry


def fall(state):
    state.step += 1
    if state.init:
        state.status = 'fall'
    state.level -= 1
    if state.level < 0:
        raise ValueError('crash')
    return fall  # retry until crash!


def finish(state):
    return Finish


class Result:
    cleanup_reason = None

    def __init__(self):
        self.states = []

    def on_error(self, sm):
        self.cleanup_reason = sm.cleanup_reason

    def on_transition(self, sm, newstate):
        self.states.append(newstate)


def test_fun():
    obj = Result()
    s = StateMachine(step=0, status='', transition=obj.on_transition, logger=LoggerStub())
    assert s.step == 0
    assert s.status == ''
    s.cycle()  # do nothing
    assert s.step == 0
    s.start(rise, cleanup=obj.on_error, level=0, direction=0)
    s.cycle()
    for i in range(1, 4):
        assert s.status == 'rise'
        assert s.step == i
        assert s.level == i
        assert s.direction == 0
        s.cycle()
    for i in range(5, 8):
        assert s.status == 'turn'
        assert s.step == i
        assert s.level == 4
        assert s.direction == i - 4
        s.cycle()
    s.cycle()  # -> crash
    assert isinstance(obj.cleanup_reason, ValueError)
    assert str(obj.cleanup_reason) == 'crash'
    assert obj.states == [rise, turn, fall, fall, fall, fall, fall, None]
    assert s.statefunc is None


def test_max_chain():
    obj = Result()
    s = StateMachine(step=0, status='', transition=obj.on_transition, logger=LoggerStub())
    s.start(fall, cleanup=obj.on_error, level=999+1, direction=0)
    s.cycle()
    assert isinstance(obj.cleanup_reason, RuntimeError)
    assert s.statefunc is None


def test_stop():
    obj = Result()
    s = StateMachine(step=0, status='', transition=obj.on_transition, logger=LoggerStub())
    s.start(rise, cleanup=obj.on_error, level=0, direction=0)
    for _ in range(3):
        s.cycle()
    s.stop()
    s.cycle()
    assert isinstance(obj.cleanup_reason, Stop)
    assert obj.states == [rise, None]
    assert s.statefunc is None


def test_error_handling():
    obj = Result()
    s = StateMachine(step=0, status='', transition=obj.on_transition, logger=LoggerStub())
    s.start(rise, cleanup=obj.on_error, level=0, direction=0)
    s.cycle()
    s.cycle()
    s.level = None
    s.cycle()
    assert isinstance(obj.cleanup_reason, TypeError)
    assert obj.states == [rise, None]
    assert s.statefunc is None


def test_on_restart():
    obj = Result()
    s = StateMachine(step=0, status='', transition=obj.on_transition, logger=LoggerStub())
    s.start(rise, cleanup=obj.on_error, level=0, direction=0)
    s.cycle()
    s.cycle()
    s.start(turn)
    s.cycle()
    assert isinstance(obj.cleanup_reason, Start)
    obj.cleanup_reason = None
    s.cycle()
    assert s.statefunc is turn
    assert obj.cleanup_reason is None
    assert obj.states == [rise, None, turn]


def test_finish():
    obj = Result()
    s = StateMachine(step=0, status='', transition=obj.on_transition, logger=LoggerStub())
    s.start(finish, cleanup=obj.on_error, level=0, direction=0)
    s.cycle()
    s.cycle()
    assert obj.states == [finish, None]
    assert s.statefunc is None
    assert s.cleanup_reason is None


Status = Enum(
    Drivable.Status,
    PREPARED=150,
    PREPARING=340,
    RAMPING=370,
    STABILIZING=380,
    FINALIZING=390,
)


class DispatcherStub:
    # the first update from the poller comes a very short time after the
    # initial value from the timestamp. However, in the test below
    # the second update happens after the updates dict is cleared
    # -> we have to inhibit the 'omit unchanged update' feature

    def __init__(self, updates):
        generalConfig.testinit(omit_unchanged_within=0)
        self.updates = updates

    def announce_update(self, moduleobj, pobj):
        assert moduleobj.name == 'obj'
        if pobj.readerror:
            self.updates.append((pobj.name, pobj.readerror))
        else:
            self.updates.append((pobj.name, pobj.value))


class ServerStub:
    def __init__(self, updates):
        self.dispatcher = DispatcherStub(updates)
        self.secnode = None


class Mod(HasStates, Drivable):
    status = Parameter(datatype=StatusType(Status))
    _my_time = 0

    def artificial_time(self):
        return self._my_time

    def state_transition(self, sm, newstate):
        self.statelist.append(getattr(newstate, '__name__', None))
        super().state_transition(sm, newstate)

    def state_one(self, sm):
        if sm.init:
            return Retry
        return self.state_two

    @status_code('PREPARING', 'state 2')
    def state_two(self, sm):
        return self.state_three

    @status_code('FINALIZING')
    def state_three(self, sm):
        if sm.init:
            return Retry
        return self.final_status('IDLE', 'finished')

    def cleanup_one(self, sm):
        self.statelist.append('cleanup one')
        return self.cleanup_two

    @status_code('BUSY', 'after cleanup')
    def cleanup_two(self, sm):
        if sm.init:
            return Retry
        return Finish

    def doPoll(self):
        super().doPoll()
        self._my_time += 1


class Started(RuntimeError):
    pass


def create_module():
    updates = []
    obj = Mod('obj', LoggerStub(), {'description': ''}, ServerStub(updates))
    obj.initModule()
    obj.statelist = []
    try:
        def started():
            raise Started()
        # run __pollThread until Started is raised (after initial phase)
        obj._Module__pollThread(obj.polledModules, started)
    except Started:
        pass
    updates.clear()
    return obj, updates


def test_updates():
    obj, updates = create_module()
    obj.start_machine(obj.state_one)
    for _ in range(10):
        obj.doPoll()
    assert updates == [
        ('status', (Status.BUSY, 'state one')),         # default: BUSY, function name without '_'
        ('status', (Status.PREPARING, 'state 2')),      # explicitly given
        ('status', (Status.FINALIZING, 'state three')),  # only code given
        ('status', (Status.IDLE, 'finished')),
    ]


def test_stop_without_cleanup():
    obj, updates = create_module()
    obj.start_machine(obj.state_one)
    obj.doPoll()
    obj.stop_machine()
    for _ in range(10):
        obj.doPoll()
    assert updates == [
        ('status', (Status.BUSY, 'state one')),
        ('status', (Status.BUSY, 'stopping')),
        ('status', (Status.IDLE, 'stopped')),
    ]
    assert obj.statelist == ['state_one', None]


def test_stop_with_cleanup():
    obj, updates = create_module()
    obj.start_machine(obj.state_one, cleanup=obj.cleanup_one)
    obj.doPoll()
    obj.stop_machine()
    for _ in range(10):
        obj.doPoll()
    assert obj.statelist == ['state_one', 'cleanup one', 'cleanup_two', None]
    assert updates == [
        ('status', (Status.BUSY, 'state one')),
        ('status', (Status.BUSY, 'stopping')),
        ('status', (Status.BUSY, 'stopping (after cleanup)')),
        ('status', (Status.IDLE, 'stopped')),
    ]


def test_all_restart():
    obj, updates = create_module()
    obj.start_machine(obj.state_one, cleanup=obj.cleanup_one, statelist=[])
    obj.doPoll()
    obj.start_machine(obj.state_three)
    for _ in range(10):
        obj.doPoll()
    assert obj.statelist == ['state_one', 'cleanup one', 'cleanup_two', None, 'state_three', None]
    assert updates == [
        ('status', (Status.BUSY, 'state one')),
        ('status', (Status.FINALIZING, 'restarting')),
        ('status', (Status.FINALIZING, 'restarting (after cleanup)')),
        ('status', (Status.FINALIZING, 'state three')),
        ('status', (Status.IDLE, 'finished')),
    ]
