#   pyRCV2: Preferential vote counting
#   Copyright © 2020  Lee Yingtong Li (RunasSudo)
#
#   This program is free software: you can redistribute it and/or modify
#   it under the terms of the GNU Affero General Public License as published by
#   the Free Software Foundation, either version 3 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 Affero General Public License for more details.
#
#   You should have received a copy of the GNU Affero General Public License
#   along with this program.  If not, see <https://www.gnu.org/licenses/>.

__pragma__ = lambda x: None

from pyRCV2.model import CandidateState, CountCard, CountCompleted, CountStepResult
from pyRCV2.numbers import Num, Rational
from pyRCV2.safedict import SafeDict

import itertools

class STVException(Exception):
	pass

class BaseSTVCounter:
	"""
	Basic STV counter for various different variations
	"""
	
	def __init__(self, election, options=None):
		self.election = election
		
		self.cls_ballot_value = Num # Need to use Rational in unweighted inclusive Gregory
		
		# Default options
		self.options = {
			'prog_quota': False,      # Progressively reducing quota?
			'bulk_elect': True,       # Bulk election?
			'quota': 'droop',         # 'droop', 'droop_exact', 'hare' or 'hare_exact'
			'quota_criterion': 'geq', # 'geq' or 'gt'
			'surplus_order': 'size',  # 'size' or 'order'
			'papers': 'both',         # 'both' or 'transferable'
			'exclusion': 'one_round', # 'one_round', 'parcels_by_order' or 'by_value'
			'ties': []
		}
		
		if options is not None:
			self.options.update(options)
		
		self.candidates = SafeDict([(c, CountCard()) for c in self.election.candidates])
		self.exhausted = CountCard()
		self.loss_fraction = CountCard()
		
		self.total_orig = sum((b.value for b in self.election.ballots), Num('0'))
		
		self.num_elected = 0
		
		# Withdraw candidates
		for candidate in self.election.withdrawn:
			__pragma__('opov')
			self.candidates[candidate].state = CandidateState.WITHDRAWN
			__pragma__('noopov')
	
	def reset(self):
		"""
		Public function:
		Perform the first step (distribute first preferences)
		Does not reset the states of candidates, etc.
		"""
		
		self.distribute_first_preferences()
		
		self.quota = None
		self.compute_quota()
		self.elect_meeting_quota()
		
		__pragma__('opov')
		result = CountStepResult(
			'First preferences',
			self.candidates,
			self.exhausted,
			self.loss_fraction,
			self.total + self.exhausted.votes + self.loss_fraction.votes,
			self.quota
		)
		__pragma__('noopov')
		
		self.step_results = [result]
		return result
	
	def distribute_first_preferences(self):
		"""
		Distribute first preferences (called as part of the reset() step)
		"""
		
		for ballot in self.election.ballots:
			__pragma__('opov')
			candidate = next((c for c in ballot.preferences if self.candidates[c].state == CandidateState.HOPEFUL), None)
			
			if candidate is not None:
				self.candidates[candidate].transfers += ballot.value
				if len(self.candidates[candidate].parcels) == 0:
					self.candidates[candidate].parcels.append([(ballot, self.cls_ballot_value(ballot.value))])
				else:
					self.candidates[candidate].parcels[0].append((ballot, self.cls_ballot_value(ballot.value)))
			else:
				self.exhausted.transfers += ballot.value
				#self.exhausted.parcels[0].append((ballot, self.cls_ballot_value(ballot.value)))
			__pragma__('noopov')
	
	def step(self):
		"""
		Public function:
		Perform one step of the STV count
		"""
		
		# Step count cards
		self.step_count_cards()
		
		# Check if done
		result = self.before_surpluses()
		if result:
			return result
		
		# Distribute surpluses
		result = self.distribute_surpluses()
		if result:
			return result
		
		# Check if done (2)
		result = self.before_exclusion()
		if result:
			return result
		
		# Insufficient winners and no surpluses to distribute
		# Exclude the lowest ranked hopeful
		result = self.exclude_candidate()
		if result:
			return result
		
		raise STVException('Unable to complete step')
	
	def step_count_cards(self):
		"""
		Reset the count cards for the beginning of a new step
		"""
		
		for candidate, count_card in self.candidates.items():
			count_card.step()
		self.exhausted.step()
		self.loss_fraction.step()
	
	def before_surpluses(self):
		"""
		Check if the count can be completed before distributing surpluses
		"""
		
		# Have sufficient candidates been elected?
		if self.num_elected >= self.election.seats:
			return CountCompleted()
		
		# Are there just enough candidates to fill all the seats?
		if self.options['bulk_elect']:
			if self.num_elected + sum(1 for c, cc in self.candidates.items() if cc.state == CandidateState.HOPEFUL or cc.state == CandidateState.EXCLUDING) <= self.election.seats:
				# Declare elected all remaining candidates
				for candidate, count_card in self.candidates.items():
					if count_card.state == CandidateState.HOPEFUL:
						count_card.state = CandidateState.PROVISIONALLY_ELECTED
						count_card.order_elected = self.num_elected
						self.num_elected += 1
				
				__pragma__('opov')
				result = CountStepResult(
					'Bulk election',
					self.candidates,
					self.exhausted,
					self.loss_fraction,
					self.total + self.exhausted.votes + self.loss_fraction.votes,
					self.quota
				)
				__pragma__('noopov')
				
				self.step_results.append(result)
				return result
	
	def distribute_surpluses(self):
		"""
		Distribute surpluses, if any
		"""
		
		# Do not interrupt an exclusion
		if any(cc.state == CandidateState.EXCLUDING for c, cc in self.candidates.items()):
			return
		
		# Are we distributing a surplus?
		has_surplus = [(c, cc) for c, cc in self.candidates.items() if cc.state == CandidateState.DISTRIBUTING_SURPLUS]
		
		# Do surpluses need to be distributed?
		if len(has_surplus) == 0:
			__pragma__('opov')
			has_surplus = [(c, cc) for c, cc in self.candidates.items() if cc.state == CandidateState.PROVISIONALLY_ELECTED and cc.votes > self.quota]
			__pragma__('noopov')
		
		if len(has_surplus) > 0:
			# Distribute surpluses in specified order
			if self.options['surplus_order'] == 'size':
				has_surplus.sort(key=lambda x: x[1].votes, reverse=True)
				candidate_surplus, count_card = self.choose_highest(has_surplus) # May need to break ties
			elif self.options['surplus_order'] == 'order':
				has_surplus.sort(key=lambda x: x[1].order_elected)
				candidate_surplus, count_card = has_surplus[0] # Ties were already broken when these were assigned
			else:
				raise STVException('Invalid surplus order option')
			
			count_card.state = CandidateState.DISTRIBUTING_SURPLUS
			
			__pragma__('opov')
			surplus = count_card.votes - self.quota
			__pragma__('noopov')
			
			# Transfer surplus
			self.do_surplus(candidate_surplus, count_card, surplus)
			
			# Declare elected any candidates meeting the quota as a result of surpluses
			self.compute_quota()
			
			self.elect_meeting_quota()
			
			__pragma__('opov')
			result = CountStepResult(
				'Surplus of ' + candidate_surplus.name,
				self.candidates,
				self.exhausted,
				self.loss_fraction,
				self.total + self.exhausted.votes + self.loss_fraction.votes,
				self.quota
			)
			__pragma__('noopov')
			
			self.step_results.append(result)
			return result
	
	def do_surplus(self, candidate_surplus, count_card, surplus):
		"""
		Transfer the surplus of the given candidate
		Subclasses must override this function
		"""
		raise NotImplementedError('Method not implemented')
	
	def next_preferences(self, parcels):
		"""
		Examine the specified parcels and group ballot papers by next available preference
		"""
		# SafeDict: Candidate -> [List[Ballot], ballots, votes]
		next_preferences = SafeDict([(c, [[], Num('0'), Rational('0')]) for c, cc in self.candidates.items()])
		total_ballots = Num('0')
		total_votes = Rational('0')
		
		next_exhausted = []
		exhausted_ballots = Num('0')
		exhausted_votes = Rational('0')
		
		for parcel in parcels:
			for ballot, ballot_value in parcel:
				__pragma__('opov')
				total_ballots += ballot.value
				total_votes += ballot_value.to_rational()
				candidate = next((c for c in ballot.preferences if self.candidates[c].state == CandidateState.HOPEFUL), None)
				
				if candidate is not None:
					next_preferences[candidate][0].append((ballot, ballot_value))
					next_preferences[candidate][1] += ballot.value
					next_preferences[candidate][2] += ballot_value.to_rational()
				else:
					next_exhausted.append((ballot, ballot_value))
					exhausted_ballots += ballot.value
					exhausted_votes += ballot_value.to_rational()
				
				__pragma__('noopov')
		
		return next_preferences, total_ballots, total_votes, next_exhausted, exhausted_ballots, exhausted_votes
	
	def before_exclusion(self):
		"""
		Check before excluding a candidate
		"""
		
		# If we did not perform bulk election in before_surpluses: Are there just enough candidates to fill all the seats?
		if not self.options['bulk_elect']:
			if self.num_elected + sum(1 for c, cc in self.candidates.items() if cc.state == CandidateState.HOPEFUL) <= self.election.seats:
				# Declare elected one remaining candidate at a time
				hopefuls = [(c, cc) for c, cc in self.candidates.items() if cc.state == CandidateState.HOPEFUL]
				hopefuls.sort(key=lambda x: x[1].votes, reverse=True)
				candidate_elected, count_card = self.choose_highest(hopefuls)
				
				count_card.state = CandidateState.PROVISIONALLY_ELECTED
				count_card.order_elected = self.num_elected
				self.num_elected += 1
				
				__pragma__('opov')
				result = CountStepResult(
					'Bulk election',
					self.candidates,
					self.exhausted,
					self.loss_fraction,
					self.total + self.exhausted.votes + self.loss_fraction.votes,
					self.quota
				)
				__pragma__('noopov')
				
				self.step_results.append(result)
				return result
	
	def exclude_candidate(self):
		"""
		Exclude the lowest ranked hopeful
		"""
		
		candidate_excluded, count_card = self.candidate_to_exclude()
		count_card.state = CandidateState.EXCLUDING
		
		# Exclude this candidate
		self.do_exclusion(candidate_excluded, count_card)
		
		# Declare any candidates meeting the quota as a result of exclusion
		self.compute_quota()
		
		self.elect_meeting_quota()
		
		__pragma__('opov')
		result = CountStepResult(
			'Exclusion of ' + candidate_excluded.name,
			self.candidates,
			self.exhausted,
			self.loss_fraction,
			self.total + self.exhausted.votes + self.loss_fraction.votes,
			self.quota
		)
		__pragma__('noopov')
		
		self.step_results.append(result)
		return result
	
	def candidate_to_exclude(self):
		"""
		Determine the candidate to exclude
		"""
		
		# Continue current exclusion if applicable
		excluding = [(c, cc) for c, cc in self.candidates.items() if cc.state == CandidateState.EXCLUDING]
		if len(excluding) > 0:
			return excluding[0][0], excluding[0][1]
		
		hopefuls = [(c, cc) for c, cc in self.candidates.items() if cc.state == CandidateState.HOPEFUL]
		hopefuls.sort(key=lambda x: x[1].votes)
		
		candidate_excluded, count_card = self.choose_lowest(hopefuls)
		
		return candidate_excluded, count_card
	
	def do_exclusion(self, candidate_excluded, count_card):
		"""
		Exclude the given candidate and transfer the votes
		Subclasses must override this function
		"""
		raise NotImplementedError('Method not implemented')
	
	def compute_quota(self):
		"""
		Recount total votes and (if applicable) recalculate the quota
		"""
		
		__pragma__('opov')
		self.total = sum((cc.votes for c, cc in self.candidates.items()), Num('0'))
		self.loss_fraction.transfers += (self.total_orig - self.total - self.exhausted.votes) - self.loss_fraction.votes
		
		if self.quota is None or self.options['prog_quota']:
			if self.options['quota'] == 'droop':
				self.quota = (self.total / Num(self.election.seats + 1)).__floor__() + Num('1')
			elif self.options['quota'] == 'droop_exact':
				self.quota = self.total / Num(self.election.seats + 1)
			elif self.options['quota'] == 'hare':
				self.quota = (self.total / Num(self.election.seats)).__floor__() + Num('1')
			elif self.options['quota'] == 'hare_exact':
				self.quota = self.total / Num(self.election.seats)
			else:
				raise STVException('Invalid quota option')
		__pragma__('noopov')
	
	def meets_quota(self, count_card):
		"""
		Determine if the given candidate meets the quota
		"""
		
		if self.options['quota_criterion'] == 'geq':
			__pragma__('opov')
			return count_card.votes >= self.quota
			__pragma__('noopov')
		elif self.options['quota_criterion'] == 'gt':
			__pragma__('opov')
			return count_card.votes > self.quota
			__pragma__('noopov')
		else:
			raise STVException('Invalid quota criterion')
	
	def elect_meeting_quota(self):
		"""
		Elect all candidates meeting the quota
		"""
		
		# Does a candidate meet the quota?
		meets_quota = [(c, cc) for c, cc in self.candidates.items() if cc.state == CandidateState.HOPEFUL and self.meets_quota(cc)]
		
		if len(meets_quota) > 0:
			meets_quota.sort(key=lambda x: x[1].votes, reverse=True)
			
			# Declare elected any candidate who meets the quota
			while len(meets_quota) > 0:
				x = self.choose_highest(meets_quota)
				candidate, count_card = x[0], x[1]
				
				count_card.state = CandidateState.PROVISIONALLY_ELECTED
				count_card.order_elected = self.num_elected
				self.num_elected += 1
				
				meets_quota.remove(x)
	
	def choose_lowest(self, l):
		"""
		Provided a list of tuples (Candidate, CountCard), sorted in ASCENDING order of votes, choose the tuple with the fewest votes, breaking ties appropriately
		"""
		
		if len(l) == 1:
			return l[0]
		
		__pragma__('opov')
		# Do not use (c, cc) for c, cc in ... as this will break equality in JS
		tied = [x for x in l if x[1].votes == l[0][1].votes]
		__pragma__('noopov')
		
		if len(tied) == 1:
			return tied[0]
		
		# A tie exists
		for tie in self.options['ties']:
			result = tie.choose_lowest(tied)
			if result is not None:
				return result
		
		raise Exception('Unable to resolve tie')
	
	def choose_highest(self, l):
		"""
		Provided a list of tuples (Candidate, CountCard), sorted in DESCENDING order of votes, choose the tuple with the most votes, breaking ties appropriately
		"""
		
		if len(l) == 1:
			return l[0]
		
		__pragma__('opov')
		# Do not use (c, cc) for c, cc in ... as this will break equality in JS
		tied = [x for x in l if x[1].votes == l[0][1].votes]
		__pragma__('noopov')
		
		if len(tied) == 1:
			return tied[0]
		
		# A tie exists
		for tie in self.options['ties']:
			result = tie.choose_highest(tied)
			if result is not None:
				return result
		
		raise Exception('Unable to resolve tie')

class WIGSTVCounter(BaseSTVCounter):
	"""
	Basic weighted inclusive Gregory STV counter
	"""
	
	def do_surplus(self, candidate_surplus, count_card, surplus):
		next_preferences, total_ballots, total_votes, next_exhausted, exhausted_ballots, exhausted_votes = self.next_preferences(count_card.parcels)
		
		if self.options['papers'] == 'transferable':
			__pragma__('opov')
			transferable_votes = total_votes.to_num() - exhausted_votes.to_num()
			__pragma__('noopov')
		
		for candidate, x in next_preferences.items():
			cand_ballots = x[0]
			num_ballots = x[1]
			num_votes = x[2]
			
			new_parcel = []
			if len(cand_ballots) > 0:
				__pragma__('opov')
				self.candidates[candidate].parcels.append(new_parcel)
				__pragma__('noopov')
			
			__pragma__('opov')
			if self.options['papers'] == 'transferable':
				if transferable_votes > surplus:
					self.candidates[candidate].transfers += (num_votes.to_num() * surplus) / transferable_votes
				else:
					self.candidates[candidate].transfers += num_votes.to_num() # Do not allow weight to increase
			else:
				self.candidates[candidate].transfers += (num_votes.to_num() * surplus) / total_votes.to_num()
			__pragma__('noopov')
			
			for ballot, ballot_value in cand_ballots:
				__pragma__('opov')
				if self.options['papers'] == 'transferable':
					if transferable_votes > surplus:
						new_value = (ballot_value * surplus) / transferable_votes
					else:
						new_value = ballot_value
				else:
					new_value = (ballot_value * surplus) / total_votes.to_num()
				
				new_parcel.append((ballot, new_value))
				__pragma__('noopov')
		
		__pragma__('opov')
		if self.options['papers'] == 'transferable':
			if transferable_votes > surplus:
				pass # No ballots exhaust
			else:
				self.exhausted.transfers += (surplus - transferable_votes)
		else:
			self.exhausted.transfers += (exhausted_votes.to_num() * surplus) / total_votes.to_num()
		__pragma__('noopov')
		
		__pragma__('opov')
		count_card.transfers -= surplus
		__pragma__('noopov')
		
		count_card.state = CandidateState.ELECTED
	
	def do_exclusion(self, candidate_excluded, count_card):
		if self.options['exclusion'] == 'parcels_by_order':
			if len(count_card.parcels) > 0:
				parcel = count_card.parcels[0]
				next_preferences, total_ballots, total_votes, next_exhausted, exhausted_ballots, exhausted_votes = self.next_preferences([parcel])
			else:
				# TODO: Skip this entirely if this is the case
				parcel = []
			count_card.parcels.remove(parcel)
		elif self.options['exclusion'] == 'by_value':
			# Sort the ballots by value
			ballots = [(b, bv) for p in count_card.parcels for b, bv in p]
			__pragma__('opov')
			ballots.sort(key=lambda x: x[1] / x[0].value.to_rational(), reverse=True)
			count_card.parcels = [list(g) for k, g in itertools.groupby(ballots, lambda x: x[1] / x[0].value.to_rational())]
			__pragma__('noopov')
			
			if len(count_card.parcels) > 0:
				parcel = count_card.parcels[0]
				count_card.parcels.remove(parcel)
			else:
				parcel = []
			
			next_preferences, total_ballots, total_votes, next_exhausted, exhausted_ballots, exhausted_votes = self.next_preferences([parcel])
		else: # one_round
			next_preferences, total_ballots, total_votes, next_exhausted, exhausted_ballots, exhausted_votes = self.next_preferences(count_card.parcels)
			count_card.parcels = []
		
		for candidate, x in next_preferences.items():
			cand_ballots = x[0]
			num_ballots = x[1]
			num_votes = x[2]
			
			new_parcel = []
			if len(cand_ballots) > 0:
				__pragma__('opov')
				self.candidates[candidate].parcels.append(new_parcel)
				__pragma__('noopov')
			
			__pragma__('opov')
			self.candidates[candidate].transfers += num_votes.to_num()
			__pragma__('noopov')
			
			for ballot, ballot_value in cand_ballots:
				__pragma__('opov')
				new_parcel.append((ballot, ballot_value))
				__pragma__('noopov')
		
		__pragma__('opov')
		self.exhausted.transfers += exhausted_votes.to_num()
		__pragma__('noopov')
		
		__pragma__('opov')
		count_card.transfers -= total_votes.to_num()
		__pragma__('noopov')
		
		if len(count_card.parcels) == 0:
			__pragma__('opov')
			count_card.transfers -= count_card.votes
			__pragma__('noopov')
			count_card.state = CandidateState.EXCLUDED

class UIGSTVCounter(WIGSTVCounter):
	"""
	Basic unweighted inclusive Gregory STV counter
	"""
	
	def __init__(self, *args):
		WIGSTVCounter.__init__(self, *args)
		# Need to use Rational for ballot value internally, as Num may be set to integers only
		self.cls_ballot_value = lambda x: x.to_rational()
	
	def do_surplus(self, candidate_surplus, count_card, surplus):
		next_preferences, total_ballots, total_votes, next_exhausted, exhausted_ballots, exhausted_votes = self.next_preferences(count_card.parcels)
		
		if self.options['papers'] == 'transferable':
			__pragma__('opov')
			transferable_ballots = total_ballots - exhausted_ballots # Num
			transferable_votes = total_votes - exhausted_votes # Rational
			__pragma__('noopov')
		
		for candidate, x in next_preferences.items():
			cand_ballots = x[0]
			num_ballots = x[1] # Num
			num_votes = x[2] # Rational
			
			new_parcel = []
			if len(cand_ballots) > 0:
				__pragma__('opov')
				self.candidates[candidate].parcels.append(new_parcel)
				__pragma__('noopov')
			
			__pragma__('opov')
			if self.options['papers'] == 'transferable':
				if transferable_votes.to_num() > surplus:
					self.candidates[candidate].transfers += (num_ballots * surplus) / transferable_ballots
				else:
					self.candidates[candidate].transfers += num_votes.to_num()
			else:
				self.candidates[candidate].transfers += (num_ballots * surplus) / total_ballots
			__pragma__('noopov')
			
			for ballot, ballot_value in cand_ballots:
				__pragma__('opov')
				if self.options['papers'] == 'transferable':
					if transferable_votes.to_num() > surplus:
						new_value = (ballot.value * surplus).to_rational() / transferable_ballots.to_rational()
					else:
						new_value = ballot_value
				else:
					new_value = (ballot.value * surplus).to_rational() / total_ballots.to_rational()
				
				new_parcel.append((ballot, new_value))
				__pragma__('noopov')
		
		__pragma__('opov')
		if self.options['papers'] == 'transferable':
			if transferable_votes.to_num() > surplus:
				pass # No ballots exhaust
			else:
				self.exhausted.transfers += surplus - transferable_votes.to_num()
		else:
			self.exhausted.transfers += (exhausted_ballots * surplus) / total_ballots
		__pragma__('noopov')
		
		__pragma__('opov')
		count_card.transfers -= surplus
		__pragma__('noopov')
		
		count_card.state = CandidateState.ELECTED

class EGSTVCounter(UIGSTVCounter):
	"""
	Exclusive Gregory (last bundle) STV implementation
	"""
	
	def do_surplus(self, candidate_surplus, count_card, surplus):
		"""Overrides UIGSTVCounter.do_surplus"""
		
		last_bundle = count_card.parcels[len(count_card.parcels)-1]
		next_preferences, total_ballots, total_votes, next_exhausted, exhausted_ballots, exhausted_votes = self.next_preferences([last_bundle])
		
		if self.options['papers'] == 'transferable':
			__pragma__('opov')
			transferable_ballots = total_ballots - exhausted_ballots
			transferable_votes = total_votes.to_num() - exhausted_votes.to_num()
			__pragma__('noopov')
		
		for candidate, x in next_preferences.items():
			cand_ballots = x[0]
			num_ballots = x[1]
			num_votes = x[2]
			
			new_parcel = []
			if len(cand_ballots) > 0:
				__pragma__('opov')
				self.candidates[candidate].parcels.append(new_parcel)
				__pragma__('noopov')
			
			__pragma__('opov')
			if self.options['papers'] == 'transferable':
				if transferable_votes > surplus:
					self.candidates[candidate].transfers += (num_ballots * surplus) / transferable_ballots
				else:
					self.candidates[candidate].transfers += num_votes.to_num()
			else:
				self.candidates[candidate].transfers += (num_ballots * surplus) / total_ballots
			__pragma__('noopov')
			
			for ballot, ballot_value in cand_ballots:
				__pragma__('opov')
				if self.options['papers'] == 'transferable':
					if transferable_votes > surplus:
						new_value = (ballot.value * surplus) / transferable_ballots
					else:
						new_value = ballot_value
				else:
					new_value = (ballot.value * surplus) / total_ballots
				
				new_parcel.append((ballot, new_value))
				__pragma__('noopov')
		
		__pragma__('opov')
		if self.options['papers'] == 'transferable':
			if transferable_votes > surplus:
				pass # No ballots exhaust
			else:
				self.exhausted.transfers += (surplus - transferable_votes)
		else:
			self.exhausted.transfers += (exhausted_ballots * surplus) / total_ballots
		__pragma__('noopov')
		
		__pragma__('opov')
		count_card.transfers -= surplus
		__pragma__('noopov')
		
		count_card.state = CandidateState.ELECTED
