module ICU

=begin rdoc

== Building a Tournament

One way to create a tournament object is by parsing one of the supported file types (e.g. ICU::Tournament::Krause).
It is also possible to build one programmatically by:

1. creating a bare tournament instance,
2. adding all the players,
3. adding all the results.

For example:

  require 'rubygems'
  require 'icu_tournament'

  t = ICU::Tournament.new('Bangor Masters', '2009-11-09')

  t.add_player(ICU::Player.new('Bobby', 'Fischer', 10))
  t.add_player(ICU::Player.new('Garry', 'Kasparov', 20))
  t.add_player(ICU::Player.new('Mark', 'Orr', 30))

  t.add_result(ICU::Result.new(1, 10, 'D', :opponent => 30, :colour => 'W'))
  t.add_result(ICU::Result.new(2, 20, 'W', :opponent => 30, :colour => 'B'))
  t.add_result(ICU::Result.new(3, 20, 'L', :opponent => 10, :colour => 'W'))

  t.validate!(:rerank => true)

and then:

  serializer = ICU::Tournament::Krause.new
  puts serializer.serialize(@t)

or equivalntly, just:

  puts t.serialize('Krause')

would result in the following output:

  012 Bangor Masters
  042 2009-11-09
  001   10      Fischer,Bobby                                                      1.5    1    30 w =              20 b 1
  001   20      Kasparov,Garry                                                     1.0    2              30 b 1    10 w 0
  001   30      Orr,Mark                                                           0.5    3    10 b =    20 w 0

Note that the players should be added first because the _add_result_ method will
raise an exception if the players it references through their tournament numbers
(10, 20 and 30 in this example) have not already been added to the tournament.

See ICU::Player and ICU::Result for more details about players and results.


== Validation

A tournament can be validated with either the <em>validate!</em> or _invalid_ methods.
On success, the first returns true while the second returns false.
On error, the first throws an exception while the second returns a string
describing the error.

Validations checks that:

* there are at least two players
* every player has a least one result
* the result round numbers are consistent (no more than one game per player per round)
* the tournament dates (start, finish, round dates), if there are any, are consistent
* the player ranks are consistent with their scores

Side effects of calling <em>validate!</em> or _invalid_ include:

* the number of rounds will be set if not set already
* the finish date will be set if not set already and if there are round dates

Optionally, additional validation checks can be performed given a tournament
parser/serializer. For example:

  t.validate!(:type => ICU::Tournament.ForeignCSV.new)

Or equivalently:

  t.validate!(:type => 'ForeignCSV')

Such additional validation is always performed before a tournament is serialized.
For example, the following are equivalent and will throw an exception if
the tournament is invalid according to either the general rules or the rules
specific for the type used:

  t.serialize('ForeignCSV')
  ICU::Tournament::ForeignCSV.new.serialize(t)

== Ranking

The players in a tournament can be ranked by calling the _rerank_ method directly.

  t.rerank

Alternatively they can be ranked as a side effect of validation if the _rerank_ option is set,
but this only applies if the tournament is not yet ranked or it's ranking is inconsistent.

  t.validate(:rerank => true)

Ranking is inconsistent if some but not all players have a rank or if all players
have a rank but some are ranked higher than others on lower scores.

To rank the players requires a tie break method to be specified to order players on the same score.
The default is alphabetical (by last name then first name). Other methods can be specified by supplying
an array of methods (strings or symbols) in order of precedence to the _tie_breaks_ setter. Examples:

  t.tie_breaks = ['Sonneborn-Berger']
  t.tie_breaks = [:buchholz, :neustadtl, :blacks, :wins]
  t.tie_breaks = []  # reset to the default

The full list of supported methods is:

* _Buchholz_: sum of opponents' scores
* _Harkness_ (or _median_): like Buchholz except the highest and lowest opponents' scores are discarded (or two highest and lowest if 9 rounds or more)
* _modified_median_: same as Harkness except only lowest (or highest) score(s) are discarded for players with more (or less) than 50%
* _Neustadtl_ (or _Sonneborn-Berger_): sum of scores of players defeated plus half sum of scores of players drawn against
* _progressive_ (or _cumulative_): sum of running score for each round
* _ratings_: sum of opponents ratings
* _blacks_: number of blacks
* _wins_: number of wins
* _name_: alphabetical by name (if _tie_breaks_ is set to an empty array, as it is initially, then this will be used as the back-up tie breaker)

The return value from _rerank_ is the tournament object itself, to allow chaining, for example:

  t.rerank.renumber


== Renumbering

The numbers used to uniquely identify each player in a tournament can be any set of unique integers
(including zero and negative numbers). To renumber the players so that these numbers start at 1 and
end with the total number of players, use the _renumber_ method. This method takes one optional
argument to specify how the renumbering is done.

  t.renumber(:rank)       # renumber by rank (if there are consistent rankings), otherwise by name alphabetically
  t.renumber              # the same, as renumbering by rank is the default
  t.renumber(:name)       # renumber by name alphabetically
  t.renumber(:order)      # renumber maintaining the order of the original numbers

The return value from _renumber_ is the tournament object itself.


== Parsing Files

As an alternative to processing files by first instantiating a parser of the appropropriate class
(such as ICU::Tournament::SwissPerfect, ICU::Tournament::Krause and ICU::Tournament::ForeignCSV)
and then calling the parser's <em>parse_file</em> or <em>parse_file!</em> instance method,
a convenience class method, <em>parse_file!</em>, is available when a parser instance is not required.
For example:

  t = ICU::Tournament.parse_file!('champs.zip', 'SwissPerfect', :start => '2010-07-03')

The method takes a filename, format and an options hash as arguments. It either returns
an instance of ICU::Tournament or throws an exception. See the documentation for the
different formats for what options are available. For some, no options are available,
in which case any options supplied to this method will be silently ignored.

=end

  class Tournament

    extend ICU::Accessor
    attr_date :start
    attr_date_or_nil :finish
    attr_positive_or_nil :rounds
    attr_string %r%[a-z]%i, :name
    attr_string_or_nil %r%[a-z]%i, :city, :type, :arbiter, :deputy
    attr_string_or_nil %r%[1-9]%i, :time_control

    attr_reader :round_dates, :site, :fed, :teams, :tie_breaks

    # Constructor. Name and start date must be supplied. Other attributes are optional.
    def initialize(name, start, opt={})
      self.name  = name
      self.start = start
      [:finish, :rounds, :site, :city, :fed, :type, :arbiter, :deputy, :time_control].each { |a| self.send("#{a}=", opt[a]) unless opt[a].nil? }
      @player = {}
      @teams = []
      @round_dates = []
      @tie_breaks = []
    end

    # Set the tournament federation. Can be _nil_.
    def fed=(fed)
      obj = Federation.find(fed)
      @fed = obj ? obj.code : nil
      raise "invalid tournament federation (#{fed})" if @fed.nil? && fed.to_s.strip.length > 0
    end

    # Add a round date.
    def add_round_date(round_date)
      round_date = round_date.to_s.strip
      parsed_date = Util.parsedate(round_date)
      raise "invalid round date (#{round_date})" unless parsed_date
      @round_dates << parsed_date
      @round_dates.sort!
    end

    # Return the date of a given round, or nil if unavailable.
    def round_date(round)
      @round_dates[round-1]
    end

    # Return the greatest round number according to the players results (which may not be the same as the set number of rounds).
    def last_round
      last_round = 0
      @player.values.each do |p|
        p.results.each do |r|
          last_round = r.round if r.round > last_round
        end
      end
      last_round
    end

    # Set the tournament web site. Should be either unknown (_nil_) or a reasonably valid looking URL.
    def site=(site)
      @site = site.to_s.strip
      @site = nil if @site == ''
      @site = "http://#{@site}" if @site && !@site.match(/^https?:\/\//)
      raise "invalid site (#{site})" unless @site.nil? || @site.match(/^https?:\/\/[-\w]+(\.[-\w]+)+(\/[^\s]*)?$/i)
    end

    # Add a new team. The argument is either a team (possibly already with members) or the name of a new team.
    # The team's name must be unique in the tournament. Returns the the team instance.
    def add_team(team)
      team = Team.new(team.to_s) unless team.is_a? Team
      raise "a team with a name similar to '#{team.name}' already exists" if self.get_team(team.name)
      @teams << team
      team
    end

    # Return the team object that matches a given name, or nil if not found.
    def get_team(name)
      @teams.find{ |t| t.matches(name) }
    end

    # Set the tie break methods.
    def tie_breaks=(tie_breaks)
      raise "argument error - always set tie breaks to an array" unless tie_breaks.class == Array
      # Canonicalise the tie break method names.
      tie_breaks.map! do |m|
        m = m.to_s if m.class == Symbol
        m = m.downcase.gsub(/[-\s]/, '_') if m.class == String
        case m
          when true                then 'name'
          when 'sonneborn_berger'  then 'neustadtl'
          when 'modified_median'   then 'modified'
          when 'median'            then 'harkness'
          when 'cumulative'        then 'progressive'
          else m
        end
      end

      # Check they're all valid.
      tie_breaks.each { |m| raise "invalid tie break method '#{m}'" unless m.to_s.match(/^(blacks|buchholz|harkness|modified|name|neustadtl|progressive|ratings|wins)$/) }

      # Finally set them.
      @tie_breaks = tie_breaks;
    end

    # Add a new player to the tournament. Must have a unique player number.
    def add_player(player)
      raise "invalid player" unless player.class == ICU::Player
      raise "player number (#{player.num}) should be unique" if @player[player.num]
      @player[player.num] = player
    end

    # Get a player by their number.
    def player(num)
      @player[num]
    end

    # Return an array of all players in order of their player number.
    def players
      @player.values.sort_by{ |p| p.num }
    end

    # Lookup a player in the tournament by player number, returning _nil_ if the player number does not exist.
    def find_player(player)
      players.find { |p| p == player }
    end

    # Add a result to a tournament. An exception is raised if the players referenced in the result (by number)
    # do not exist in the tournament. The result, which remember is from the perspective of one of the players,
    # is added to that player's results. Additionally, the reverse of the result is automatically added to the player's
    # opponent, unless the opponent does not exist (e.g. byes, walkovers). By default, if the result is rateable
    # then the opponent's result will also be rateable. To make the opponent's result unrateable, set the optional
    # second parameter to false.
    def add_result(result, reverse_rateable=true)
      raise "invalid result" unless result.class == ICU::Result
      raise "result round number (#{result.round}) inconsistent with number of tournament rounds" if @rounds && result.round > @rounds
      raise "player number (#{result.player}) does not exist" unless @player[result.player]
      @player[result.player].add_result(result)
      if result.opponent
        raise "opponent number (#{result.opponent}) does not exist" unless @player[result.opponent]
        reverse = result.reverse
        reverse.rateable = false unless reverse_rateable
        @player[result.opponent].add_result(reverse)
      end
    end

    # Rerank the tournament by score first and if necessary using a configurable tie breaker method.
    def rerank
      tie_break_methods, tie_break_order, tie_break_hash = tie_break_data
      @player.values.sort do |a,b|
        cmp = 0
        tie_break_methods.each do |m|
          cmp = (tie_break_hash[m][a.num] <=> tie_break_hash[m][b.num]) * tie_break_order[m] if cmp == 0
        end
        cmp
      end.each_with_index do |p,i|
        p.rank = i + 1
      end
      self
    end

    # Return a hash (player number to value) of tie break scores for the main method.
    def tie_break_scores
      tie_break_methods, tie_break_order, tie_break_hash = tie_break_data
      main_method = tie_break_methods[1]
      scores = Hash.new
      @player.values.each { |p| scores[p.num] = tie_break_hash[main_method][p.num] }
      scores
    end

    # Renumber the players according to a given criterion.
    def renumber(criterion = :rank)
      if (criterion.class == Hash)
        # Undocumentted feature - supply your own hash.
        map = criterion
      else
        # Official way of reordering.
        map = Hash.new

        # Renumber by rank only if possible.
        criterion = criterion.to_s.downcase
        if criterion == 'rank'
          begin check_ranks rescue criterion = 'name' end
        end

        # Decide how to renumber.
        if criterion == 'rank'
          # Renumber by rank.
          @player.values.each{ |p| map[p.num] = p.rank }
        elsif criterion == 'order'
          # Just keep the existing numbers in order.
          @player.values.sort_by{ |p| p.num }.each_with_index{ |p, i| map[p.num] = i + 1 }
        else
          # Renumber by name alphabetically.
          @player.values.sort_by{ |p| p.name }.each_with_index{ |p, i| map[p.num] = i + 1 }
        end
      end

      # Apply renumbering.
      @teams.each{ |t| t.renumber(map) }
      @player = @player.values.inject({}) do |hash, player|
        player.renumber(map)
        hash[player.num] = player
        hash
      end

      # Return self for chaining.
      self
    end

    # Is a tournament invalid? Either returns false (if it's valid) or an error message.
    # Has the same _rerank_ option as validate!.
    def invalid(options={})
      begin
        validate!(options)
      rescue => err
        return err.message
      end
      false
    end

    # Raise an exception if a tournament is not valid. The _rerank_ option can be set to _true_
    # to rank the tournament just prior to the test if ranking data is missing or inconsistent.
    def validate!(options={})
      begin check_ranks rescue rerank end if options[:rerank]
      check_players
      check_rounds
      check_dates
      check_teams
      check_ranks(:allow_none => true)
      check_type(options[:type]) if options[:type]
      true
    end
    
    # Convenience method to parse a file.
    def self.parse_file!(file, format, opts={})
      type = format.to_s
      raise "Invalid format" unless type.match(/^(SwissPerfect|Krause|ForeignCSV)$/);
      parser = "ICU::Tournament::#{format}".constantize.new
      if type == 'ForeignCSV'
        # Doesn't take options.
        parser.parse_file!(file)
      else
        # The others can take options.
        parser.parse_file!(file, opts)
      end
    end

    # Convenience method to serialise the tournament into a supported format.
    # Throws an exception unless the name of a supported format is supplied
    # or if the tournament is unsuitable for serialisation in that format.
    def serialize(format, arg={})
      serializer = case format.to_s.downcase
        when 'krause'       then ICU::Tournament::Krause.new
        when 'foreigncsv'   then ICU::Tournament::ForeignCSV.new
        when 'swissperfect' then ICU::Tournament::SwissPerfect.new
        when ''             then raise "no format supplied"
        else raise "unsupported serialisation format: '#{format}'"
      end
      serializer.serialize(self, arg)
    end

    private

    # Check players.
    def check_players
      raise "the number of players (#{@player.size}) must be at least 2" if @player.size < 2
      @player.each do |num, p|
        raise "player #{num} has no results" if p.results.size == 0
        p.results.each do |r|
          next unless r.opponent
          raise "opponent #{r.opponent} of player #{num} is not in the tournament" unless @player[r.opponent]
        end
      end
    end

    # Round should go from 1 to a maximum, there should be at least one result in every round and,
    # if the number of rounds has been set, it should agree with the largest round from the results.
    def check_rounds
      round = Hash.new
      round_last = last_round
      @player.values.each do |p|
        p.results.each do |r|
          round[r.round] = true
        end
      end
      (1..round_last).each { |r| raise "there are no results for round #{r}" unless round[r] }
      if rounds
        raise "declared number of rounds is #{rounds} but there are results in later rounds, such as #{round_last}" if rounds < round_last
        raise "declared number of rounds is #{rounds} but there are no results with rounds greater than #{round_last}" if rounds > round_last
      else
        self.rounds = round_last
      end
    end

    # Check dates are consistent.
    def check_dates
      raise "start date (#{start}) is after end date (#{finish})" if @start && @finish && @start > @finish
      if @round_dates.size > 0
        raise "the number of round dates (#{@round_dates.size}) does not match the number of rounds (#{@rounds})" unless @round_dates.size == @rounds
        raise "the date of the first round (#{@round_dates[0]}) comes before the start (#{@start}) of the tournament" if @start && @start > @round_dates[0]
        raise "the date of the last round (#{@round_dates[-1]}) comes after the end (#{@finish}) of the tournament" if @finish && @finish < @round_dates[-1]
        @finish = @round_dates[-1] unless @finish
      end
    end

    # Check teams. Either there are none or:
    # * every team member is a valid player, and
    # * every player is a member of exactly one team.
    def check_teams
      return if @teams.size == 0
      member = Hash.new
      @teams.each do |t|
        t.members.each do |m|
          raise "member #{m} of team '#{t.name}' is not a valid player number for this tournament" unless @player[m]
          raise "member #{m} of team '#{t.name}' is already a member of team #{member[m]}" if member[m]
          member[m] = t.name
        end
      end
      @player.keys.each do |p|
        raise "player #{p} is not a member of any team" unless member[p]
      end
    end

    # Check if the players ranking is consistent, which will be true if:
    # * every player has a rank
    # * no two players have the same rank
    # * the highest rank is 1
    # * the lowest rank is equal to the total of players
    def check_ranks(options={})
      ranks = Hash.new
      @player.values.each do |p|
        if p.rank
          raise "two players have the same rank #{p.rank}" if ranks[p.rank]
          ranks[p.rank] = p
        end
      end
      return if ranks.size == 0 && options[:allow_none]
      raise "every player has to have a rank" unless ranks.size == @player.size
      by_rank = @player.values.sort{ |a,b| a.rank <=> b.rank}
      raise "the highest rank must be 1" unless by_rank[0].rank == 1
      raise "the lowest rank must be #{ranks.size}" unless by_rank[-1].rank == ranks.size
      if by_rank.size > 1
        (1..by_rank.size-1).each do |i|
          p1 = by_rank[i-1]
          p2 = by_rank[i]
          raise "player #{p1.num} with #{p1.points} points is ranked above player #{p2.num} with #{p2.points} points" if p1.points < p2.points
        end
      end
    end

    # Validate against a specific type.
    def check_type(type)
      if type.respond_to?(:validate!)
        type.validate!(self)
      elsif type.to_s.match(/^(ForeignCSV|Krause|SwissPerfect)$/)
        parser = "ICU::Tournament::#{type.to_s}".constantize.new.validate!(self)
      else
        raise "invalid type supplied for validation check"
      end
    end

    # Return an array of tie break methods and an array of tie break orders (+1 for asc, -1 for desc).
    # The first and most important method is always "score", the last and least important is always "name".
    def tie_break_data

      # Construct the arrays and hashes to be returned.
      methods, order, data = Array.new, Hash.new, Hash.new

      # Score is always the most important.
      methods << 'score'
      order['score'] = -1

      # Add the configured methods.
      tie_breaks.each do |m|
        methods << m
        order[m] = m == 'name' ? 1 : -1
      end

      # Name is included as the last and least important tie breaker unless it's already been added.
      unless methods.include?('name')
        methods << 'name'
        order['name'] = 1
      end

      # We'll need the number of rounds.
      rounds = last_round

      # Pre-calculate some scores that are not in themselves tie break scores
      # but are needed in the calculation of some of the actual tie-break scores.
      pre_calculated = Array.new
      pre_calculated << 'opp-score'  # sum scores where a non-played games counts 0.5
      pre_calculated.each do |m|
        data[m] = Hash.new
        @player.values.each { |p| data[m][p.num] = tie_break_score(data, m, p, rounds) }
      end

      # Now calculate all the other scores.
      methods.each do |m|
        next if pre_calculated.include?(m)
        data[m] = Hash.new
        @player.values.each { |p| data[m][p.num] = tie_break_score(data, m, p, rounds) }
      end

      # Finally, return what we calculated.
      [methods, order, data]
    end

    # Return a tie break score for a given player and a given tie break method.
    def tie_break_score(hash, method, player, rounds)
      case method
        when 'score'       then player.points
        when 'wins'        then player.results.inject(0)   { |t,r| t + (r.opponent && r.score  == 'W' ? 1 : 0) }
        when 'blacks'      then player.results.inject(0)   { |t,r| t + (r.opponent && r.colour == 'B' ? 1 : 0) }
        when 'buchholz'    then player.results.inject(0.0) { |t,r| t + (r.opponent ? hash['opp-score'][r.opponent] : 0.0) }
        when 'neustadtl'   then player.results.inject(0.0) { |t,r| t + (r.opponent ? hash['opp-score'][r.opponent] * r.points : 0.0) }
        when 'opp-score'   then player.results.inject(0.0) { |t,r| t + (r.opponent ? r.points : 0.5) } + (rounds - player.results.size) * 0.5
        when 'progressive' then (1..rounds).inject(0.0)    { |t,n| r = player.find_result(n); s = r ? r.points : 0.0; t + s * (rounds + 1 - n) }
        when 'ratings'     then player.results.inject(0)   { |t,r| t + (r.opponent && @player[r.opponent].rating ? @player[r.opponent].rating : 0) }
        when 'harkness', 'modified'
          scores = player.results.map{ |r| r.opponent ? hash['opp-score'][r.opponent] : 0.0 }.sort
          1.upto(rounds - player.results.size) { scores << 0.0 }
          half = rounds / 2.0
          times = rounds >= 9 ? 2 : 1
          if method == 'harkness' || player.points == half
            1.upto(times) { scores.shift; scores.pop }
          else
            1.upto(times) { scores.send(player.points > half ? :shift : :pop) }
          end
          scores.inject(0.0) { |t,s| t + s }
        else player.name
      end
    end
  end
end