text: #  Copyright 2017 Amazon.com, Inc. or its affiliates. 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.
  #  A copy of the License is located at
  #
  #      http://aws.amazon.com/apache2.0/
  #
  #  or in the "license" file accompanying this file. This file 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.
  
  from boto3.dynamodb.conditions import Key
  from utils import Wheel, WheelParticipant, to_update_kwargs
  from base import BadRequestError
  import random
  import contextlib
  from decimal import Decimal
  
  
  def suggest_participant(wheel):
      """
      Suggest a participant given weights of all participants with randomization.
      This is weighted selection where all participants start with a weight of 1,
      so the sum of the weights will always equal the number of participants
      :param wheel: Wheel dictionary:
      {
        "id": string ID of the wheel (DDB Hash Key),
        "name": string name of the wheel,
        "participant_count": number of participants in the wheel,
      }
      :return: ID of the suggested participant
      """
      if wheel['participant_count'] == 0:
          raise BadRequestError("Cannot suggest a participant when the wheel doesn't have any!")
  
      query_params = {'KeyConditionExpression': Key('wheel_id').eq(wheel['id'])}
  
      participants = WheelParticipant.iter_query(**query_params)
      selected_total_weight = random.random() * float(sum([participant['weight'] for participant in participants]))
  
      # We do potentially want to return the last participant just as a safeguard for rounding errors
      participant = None
      for participant in WheelParticipant.iter_query(**query_params):
          selected_total_weight -= float(participant['weight'])
          if selected_total_weight <= 0:
              return participant['id']
      return participant['id']
  
  
  def select_participant(wheel, participant):
      """
      Register the selection of a participant by updating the weights of all participants for a given wheel
      :param wheel: Wheel dictionary:
      {
        "id": string ID of the wheel (DDB Hash Key),
        "name": string name of the wheel,
        "participant_count": number of participants in the wheel,
      }
      :param participant: Participant dictionary:
      {
        "id": string ID of the participant (DDB Hash Key),
        "name": string name of the participant,
        "url": Participant's URL,
        "wheel_id": string ID of the wheel the participant belongs to,
        "weight": participant's weight in the selection algorithm
      }
      :return: None
      """
  
      num_participants = 0
      total_weight = Decimal(0)
      for p in WheelParticipant.iter_query(KeyConditionExpression=Key('wheel_id').eq(wheel['id'])):
          num_participants = num_participants+1
          total_weight += p['weight']
  
      # Factor is the number by which all weights must be multiplied
      # so total weight will be equal to the number of participants.
      factor = Decimal(num_participants) / total_weight
  
      if num_participants > 1:
          weight_share = participant['weight'] / Decimal(num_participants - 1)
          with WheelParticipant.batch_writer() as batch:
              # Redistribute and normalize the weights.
              for p in WheelParticipant.iter_query(KeyConditionExpression=Key('wheel_id').eq(wheel['id'])):
                  if p['id'] == participant['id']:
                      p['weight'] = 0
                  else:
                      p['weight'] += Decimal(weight_share)
                      p['weight'] *= factor
                  batch.put_item(Item=p)
      Wheel.update_item(
          Key={'id': wheel['id']},
          **to_update_kwargs({'participant_count': num_participants})
      )
  
  def reset_wheel(wheel):
      """
      Resets the weights of all participants in the wheel and updates the wheel's participant count
      :param wheel: Wheel dictionary:
      {
        "id": string ID of the wheel (DDB Hash Key),
        "name": string name of the wheel,
        "participant_count": number of participants in the wheel,
      }
      :return: None
      """
      count = 0
      with WheelParticipant.batch_writer() as batch:
          for p in WheelParticipant.iter_query(KeyConditionExpression=Key('wheel_id').eq(wheel['id'])):
              p['weight'] = get_sub_wheel_size(p['name'])
              batch.put_item(Item=p)
              count += 1
      Wheel.update_item(Key={'id': wheel['id']}, **to_update_kwargs({'participant_count': count}))
  
  def get_sub_wheel_size(wheel_name):
      resp = Wheel.query(
                IndexName='name_index',
                KeyConditionExpression=Key('name').eq(wheel_name)
             )
      if len(resp['Items']):  # if a matching wheel is found
          return int(resp['Items'][0]['participant_count']) or 1  # if wheel size is 0, default to 1
      return 1 # default to 1 if no matching wheel is found
  
  
  @contextlib.contextmanager
  def wrap_wheel_creation(wheel):
      wheel['participant_count'] = 0
      yield
  
  
  @contextlib.contextmanager
  def wrap_participant_creation(wheel, participant):
      participant['weight'] = get_sub_wheel_size(participant['name'])
      yield
      count = 0
      with WheelParticipant.batch_writer() as batch:
          for p in WheelParticipant.iter_query(KeyConditionExpression=Key('wheel_id').eq(wheel['id'])):
              count += 1
      Wheel.update_item(
          Key={'id': wheel['id']},
          **to_update_kwargs({'participant_count': count})
      )
  
  
  def on_participant_deletion(wheel, participant):
      """
      Normalize the remaining participant weights to account for participant removal.
      The ratio is based on the following:
       1) The participant should be at weight=1 when it leaves the system (which is the same as it arrived)
       2) That difference should be split by the remaining participants proportional by weight
          This ensures that 'weight=0' participants are still at weight=0 and that the sum of all
          weights is equal to the number of participants, so new additions are treated fairly
      :param wheel: Wheel dictionary:
      {
        "id": string ID of the wheel (DDB Hash Key),
        "name": string name of the wheel,
        "participant_count": number of participants in the wheel,
      }
      :param participant: Participant dictionary:
      {
        "id": string ID of the wheel (DDB Hash Key),
        "name": string name of the wheel,
        "url": Participant's URL,
        "wheel_id": string ID of the wheel the participant belongs to,
      }
      :return: None
      """
      total_weight = participant['weight']
      for p in WheelParticipant.iter_query(KeyConditionExpression=Key('wheel_id').eq(wheel['id'])):
          total_weight += p['weight']
  
      weight = participant['weight']
      remaining_weight = total_weight - weight  # <-- no longer presumes existing weight balance via 'int(participant_count)'
      ratio = (1 + ((weight - 1) / remaining_weight)) if (remaining_weight != 0) else 1
      num_participants = Decimal(0)
      with WheelParticipant.batch_writer() as batch:
          for p in WheelParticipant.iter_query(KeyConditionExpression=Key('wheel_id').eq(wheel['id'])):
              if p['id'] != participant['id']:
                  # This is cast to a string before turning into a decimal because of rounding/inexact guards in boto3
                  p['weight'] = Decimal(str(float(p['weight']) * float(ratio))) if (remaining_weight != 0) else \
                      get_sub_wheel_size(p['name'])
                  batch.put_item(Item=p)
                  num_participants = num_participants+1
  
      Wheel.update_item(
          Key={'id': wheel['id']},
          **to_update_kwargs({'participant_count': num_participants})
      )
  