###
# Copyright (c) 2018, Anatoly Popov
# Copyright (c) 2018, Andrey Rahmatullin
# Copyright (c) 2020, oddluck <oddluck@riseup.net>
# All rights reserved.
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are met:
#
#   * Redistributions of source code must retain the above copyright notice,
#     this list of conditions, and the following disclaimer.
#   * Redistributions in binary form must reproduce the above copyright notice,
#     this list of conditions, and the following disclaimer in the
#     documentation and/or other materials provided with the distribution.
#   * Neither the name of the author of this software nor the name of
#     contributors to this software may be used to endorse or promote products
#     derived from this software without specific prior written consent.
#
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
# ARE DISCLAIMED.  IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE
# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
# POSSIBILITY OF SUCH DAMAGE.
###

import random
import pytest
from collections import defaultdict


class RollResult:
    def __init__(self, result, lash_count=0, joie_de_vivre_target=0, suffix=""):
        self.result = result
        self.suffix = suffix
        if result < lash_count:
            self.value = 0
        elif result <= joie_de_vivre_target:
            self.value = 10
        else:
            self.value = result

    def __str__(self):
        if self.result == self.value:
            return "%d%s" % (self.result, self.suffix)
        else:
            return "%d%s [%d]" % (self.value, self.suffix, self.result)


class Raise:
    def __init__(self, raise_count=0, rolls=[]):
        self.rolls = list(
            map(lambda x: x if isinstance(x, RollResult) else RollResult(x), rolls)
        )
        self.raise_count = raise_count

    @property
    def Sum(self):
        return sum(x.value for x in self.rolls)

    def __str__(self):
        if self.raise_count == 0:
            return "(%s)" % " + ".join(map(str, self.rolls))
        else:
            return "%s(%s)" % ("*" * self.raise_count, " + ".join(map(str, self.rolls)))


class RaiseRollResult:
    def __init__(self, raises=[], unused=[], discarded=None):
        self.raises = raises
        self.unused = unused
        self.discarded = discarded

    def __str__(self):
        total_raises = sum(x.raise_count for x in self.raises)
        result = (
            "0 raises"
            if total_raises == 0
            else "%d %s: %s"
            % (
                total_raises,
                "raises" if total_raises != 1 else "raise",
                ", ".join(map(str, self.raises)),
            )
        )

        if self.unused:
            result = "%s, unused: %s" % (result, ", ".join(map(str, self.unused)))

        if self.discarded:
            result = "%s, discarded: %s" % (result, ", ".join(map(str, self.discarded)))

        return result


class RaiseAggregator:
    def __init__(self, raise_target, raises_per_target, rolls):
        self.raise_target = raise_target
        self.raises_per_target = raises_per_target
        self.ten_is_still_raise = self.raise_target == 10 or self.raises_per_target != 1
        self.exhausted = False

        self.rolled_dices = defaultdict(list)
        self.rolled_dice_count = 0
        self.dices = defaultdict(list)
        self.dice_count = 0
        self.max_roll = 0
        for x in rolls:
            self.rolled_dices[x.value].append(x)
            self.rolled_dice_count += 1
            if x.value > self.max_roll:
                self.max_roll = x.value

    def get_dice(self, numbers_to_check):
        for x in numbers_to_check:
            if len(self.dices[x]) > 0:
                self.dice_count -= 1
                return self.dices[x].pop()

        return None

    def tostr(self):
        r = "{"
        for x in self.dices:
            r += "%d: [" % x
            for y in self.dices[x]:
                r += "%s, " % str(y)
            r += "], "
        return r + "}"

    def get_lower_dice(self, target):
        return self.get_dice(range(target, 0, -1))

    def get_higher_dice(self, target):
        return self.get_dice(range(target + 1, self.max_roll + 1))

    def get_raise_candidate(self, first_dice, down):
        raise_candidate = [first_dice]
        while True:
            raise_sum = sum(x.value for x in raise_candidate)
            if raise_sum >= self.raise_target:
                return Raise(self.raises_per_target, raise_candidate)

            target = self.raise_target - raise_sum
            next_dice = (
                self.get_lower_dice(target) if down else self.get_higher_dice(target)
            )
            if next_dice is not None:
                raise_candidate.append(next_dice)
            elif self.dice_count > 0 and down:
                # we are going down. Let's grab one dice above and continue
                raise_candidate.append(self.get_higher_dice(0))
                continue
            elif self.ten_is_still_raise and raise_sum >= 10:
                return Raise(1, raise_candidate)
            else:
                return Raise(0, raise_candidate)

    def return_dice_to_pool(self, dice):
        self.dice_count += 1
        self.dices[dice.value].append(dice)

    def return_raise_to_pool(self, first_dice, raise_candidate):
        for x in raise_candidate.rolls:
            if x != first_dice:
                self.return_dice_to_pool(x)

    def __iter__(self):
        self.dices = defaultdict(list)
        self.dice_count = self.rolled_dice_count
        for value in self.rolled_dices:
            for roll in self.rolled_dices[value]:
                self.dices[value].append(roll)
        self.exhausted = False
        return self

    def __next__(self):
        if self.exhausted:
            raise StopIteration

        first_dice = self.get_lower_dice(self.max_roll)
        if first_dice is None:
            self.exhausted = True
            raise StopIteration

        lower = self.get_raise_candidate(first_dice, True)
        if lower.Sum == self.raise_target:
            return lower

        higher = self.get_raise_candidate(first_dice, False)
        if higher.raise_count == 0 and lower.raise_count == 0:
            self.exhausted = True
            self.return_raise_to_pool(first_dice, higher)
            self.return_raise_to_pool(first_dice, lower)
            self.return_dice_to_pool(first_dice)
            raise StopIteration

        if higher.raise_count == lower.raise_count:
            if higher.Sum >= lower.Sum:
                self.return_raise_to_pool(first_dice, higher)
                return lower
            else:
                self.return_raise_to_pool(first_dice, lower)
                return higher
        elif higher.raise_count > lower.raise_count:
            self.return_raise_to_pool(first_dice, lower)
            return higher
        else:
            self.return_raise_to_pool(first_dice, higher)
            return lower


class SevenSea2EdRaiseRoller:
    """
    Raise roller for 7sea, 2ed. Spec: https://redd.it/80l7jm
    """

    def __init__(
        self,
        roller,
        raise_target=10,
        raises_per_target=1,
        explode=False,
        lash_count=0,
        skill_rank=0,
        joie_de_vivre=False,
    ):
        self.roller = roller
        self.explode = skill_rank >= 5 or explode
        self.lash_count = lash_count
        self.joie_de_vivre_target = skill_rank if joie_de_vivre else 0
        self.reroll_one_dice = skill_rank >= 3
        default_roll = raise_target == 10 and raises_per_target == 1
        self.aggregator_template = lambda x: RaiseAggregator(
            15 if skill_rank >= 4 and default_roll else raise_target,
            2 if skill_rank >= 4 and default_roll else raises_per_target,
            x,
        )

    def roll_and_count(self, dice_count):
        """
        Assemble raises, according to spec
        """
        rolls = self.roll(dice_count)
        if not self.reroll_one_dice:
            discarded_dice = None
        else:
            reroll = self.roll(1, "r")
            min_value_dice = min(rolls, key=lambda x: x.value)
            if min_value_dice.value < sum(x.value for x in reroll):
                rolls.remove(min_value_dice)
                rolls += reroll
                discarded_dice = [min_value_dice]
            else:
                discarded_dice = reroll

        aggregator = self.aggregator_template(rolls)
        raises = list(aggregator)
        unused = []
        for value in aggregator.dices:
            for dice in aggregator.dices[value]:
                unused.append(dice)

        return RaiseRollResult(
            raises, sorted(unused, key=lambda x: x.value, reverse=True), discarded_dice
        )

    def roll(self, dice_count, suffix=""):
        if dice_count == 0:
            return []

        rolls = [
            RollResult(x, self.lash_count, self.joie_de_vivre_target, suffix)
            for x in self.roller(dice_count)
        ]

        return (
            rolls + self.roll(len([x for x in rolls if x.result == 10]), suffix + "x")
            if self.explode
            else rolls
        )
