"""Small guessing-game locks for the micro:bit V2.

Copy this file next to main.py in the Python editor, then use:

    from MyLocks import MyLock
    lock = MyLock.select(2)
    result = lock.guess(5)

The lock says "higher" when the secret number is bigger than the guess,
"lower" when it is smaller, and "unlocked" when the guess is right.
"""

from microbit import Image, display, running_time, sleep
import music
import random


_UNLOCKED = "unlocked"
_HIGHER = "higher"
_LOWER = "lower"
_CHECK_TIME = 500  # milliseconds; makes brute force feel like a real lock

# Give every run a fresh starting point. running_time() is different on each
# reset in normal use, so the secret target changes when the micro:bit starts.
random.seed(running_time())


def _celebrate():
    """Make the success signal. Students do not need to call this."""
    display.show(Image.HAPPY)
    music.play(["C5:2", "E5:2", "G5:2", "C6:4"])
    display.show(Image.YES)
    sleep(1000)
    display.clear()


class _Lock:
    def __init__(self, number, biggest):
        self.number = number
        self.biggest = biggest
        self.unlocked = False

    def guess(self, number):
        """Try a number and return a helpful result."""
        if self.unlocked:
            return _UNLOCKED

        if not isinstance(number, int):
            return _LOWER

        # Pretend the lock needs a moment to check its mechanism. This keeps
        # brute force possible, but makes a clever search much more useful.
        sleep(_CHECK_TIME)

        if number < 0 or number >= self.biggest:
            return _LOWER if number >= self.biggest else _HIGHER

        if number < self.number:
            return _HIGHER
        if number > self.number:
            return _LOWER

        self.unlocked = True
        _celebrate()
        return _UNLOCKED


class _AnyNumberLock(_Lock):
    def __init__(self):
        # This first lock is deliberately friendly: every valid guess works.
        _Lock.__init__(self, 0, 100000)

    def guess(self, number):
        if not self.unlocked and isinstance(number, int):
            sleep(_CHECK_TIME)
            self.unlocked = True
            _celebrate()
            return _UNLOCKED
        return _HIGHER


class _GenieLock(_Lock):
    def __init__(self):
        # A large range makes a good reason to discover interval splitting.
        _Lock.__init__(self, random.randrange(100000), 100000)
        self.has_heard_joke = False
        self.has_money = False

    def tell_joke(self):
        self.has_heard_joke = True

    def give_money(self):
        self.has_money = True

    def guess(self, number):
        # The Genie stays quiet until its two preparation functions were used.
        if not self.has_heard_joke or not self.has_money:
            return "not ready"
        return _Lock.guess(self, number)


class MyLock:
    """Factory for the four starter locks."""

    @staticmethod
    def select(lock_number):
        """Choose a lock, safely keeping the choice between 1 and 4.

        This is deliberately forgiving for beginners. For example, 0 chooses
        lock 1, 9 chooses lock 4, and an unusable value chooses lock 1.
        """
        try:
            lock_number = int(lock_number)
        except (TypeError, ValueError):
            lock_number = 1

        if lock_number < 1:
            lock_number = 1
        if lock_number > 4:
            lock_number = 4

        if lock_number == 1:
            return _AnyNumberLock()
        if lock_number == 2:
            return _Lock(random.randrange(10), 10)
        if lock_number == 3:
            return _Lock(random.randrange(1000), 1000)
        if lock_number == 4:
            return _GenieLock()

        # This is unreachable after clamping, but the explicit fallback also
        # helps simple code editors understand that select always returns a
        # lock rather than None.
        return _AnyNumberLock()
