module ICU
  #
  # 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:
  #
  # * creating a bare tournament instance,
  # * adding all the players,
  # * 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.
  #
  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