# encoding: UTF-8

module SportDb

class CsvGameReader

  include LogUtils::Logging

## make models available by default with namespace
#  e.g. lets you use Usage instead of Model::Usage
  include Models

  ##
  ## todo: add from_file and from_zip too

  def self.from_string( event_key, text )
    ### fix - fix -fix:
    ##  change event to event_or_event_key !!!!!  - allow event_key as string passed in
    self.new( event_key, text )
  end

  def initialize( event_key, text )
    ### fix - fix -fix:
    ##  change event to event_or_event_key !!!!!  - allow event_key as string passed in

    ## todo/fix: how to add opts={} ???
    @event_key         = event_key
    @text              = text
  end

  def read
    ## note: assume active activerecord connection
    @event = Event.find_by!( key: @event_key )

    logger.debug "Event #{@event.key} >#{@event.title}<"

    @team_mapper = TextUtils::TitleMapper.new( @event.teams, 'team' )

    ## reset cached values
    @patch_round_ids = []

    @last_round    = nil      ## remove last round ?? - always required - why? why not?
    @last_date     = nil      ## remove last date ??  - always required - why? why not?

    parse_fixtures
  end   # method load_fixtures

  def handle_round( round_pos_str )

    round_pos = round_pos_str.to_i

    round_attribs = { }

    round = Round.find_by( event_id: @event.id,
                           pos:      round_pos )

    if round.present?
      logger.debug "update round #{round.id}:"
    else
      logger.debug "create round:"
      round = Round.new

      round_attribs = round_attribs.merge( {
        event_id: @event.id,
        pos:      round_pos,
        title:    "Round #{round_pos}",
        title2:   nil,
        knockout: false,
        start_at: Date.parse('1911-11-11'),
        end_at:   Date.parse('1911-11-11')
      })
    end

    logger.debug round_attribs.to_json

    round.update_attributes!( round_attribs )

    ### store list of round ids for patching start_at/end_at at the end
    @patch_round_ids << round.id
    @last_round = round     ## keep track of last seen round for matches that follow etc.
  end

  def handle_game( date_str, team1_str, team2_str, ft_str, ht_str )

    ## todo/fix: make more "efficient"
    ##  - e.g. add new support method for mapping single team/reference - why? why not??
    line = "#{team1_str} - #{team2_str}"
    @team_mapper.map_titles!( line )
    team1_key = @team_mapper.find_key!( line )
    team2_key = @team_mapper.find_key!( line )

    ## note: if we do NOT find two teams; return false - no match found
    if team1_key.nil? || team2_key.nil?
      fail "  no game match (two teams required) found for line: >#{line}<"
    end

    if date_str
      date = DateTime.strptime( date_str, '%Y-%m-%d' )   ## (always) use DateTime - why? why not??
      @last_date = date    # keep a reference for later use
    else
      date = @last_date    # no date found; (re)use last seen date
    end

    ##
    ## todo: support for awarded, abadoned, a.e.t, pen. etc. - why?? why not??
    ##

    if ht_str    ## half time scores
      scoresi_str = ht_str.gsub(/ /, '').split('-')    ## note: remove all (inline) spaces first
      score1i  = scoresi_str[0].to_i
      score2i  = scoresi_str[1].to_i
    else
      score1i  = nil
      score2i  = nil
    end

    if ft_str    ## full time scores
      scores_str = ft_str.gsub(/ /, '').split('-')    ## note: remove all (inline) spaces first
      score1  = scores_str[0].to_i
      score2  = scores_str[1].to_i
    else
      score1  = nil
      score2  = nil
    end

    ### todo: cache team lookups in hash? - why? why not??
    team1 = Team.find_by!( key: team1_key )
    team2 = Team.find_by!( key: team2_key )

    round = @last_round

    ### check if games exists
    ##  with this teams in this round if yes only update
    game = Game.find_by( round_id: round.id,
                         team1_id: team1.id,
                         team2_id: team2.id )

    game_attribs = {
      score1:     score1,
      score2:     score2,
      score1i:    score1i,
      score2i:    score2i,
      play_at:    date,
      play_at_v2: nil,
      postponed:  false,
      knockout:   false,  ## round.knockout, ## note: for now always use knockout flag from round - why? why not??
      ground_id:  nil,
      group_id:   nil
    }

    if game.present?
      logger.debug "update game #{game.id}:"
    else
      logger.debug "create game:"
      game = Game.new

      ## Note: use round.games.count for pos
      ##  lets us add games out of order if later needed
      more_game_attribs = {
        round_id: round.id,
        team1_id: team1.id,
        team2_id: team2.id,
        pos:      round.games.count+1
      }
      game_attribs = game_attribs.merge( more_game_attribs )
    end

    logger.debug game_attribs.to_json
    game.update_attributes!( game_attribs )

  end # method handle_game

  def parse_fixtures

    CSV.parse( @text, headers: true ) do |row|
      puts row.inspect

      pp round = row['Round']
      pp date  = row['Date']
      pp team1 = row['Team 1']
      pp team2 = row['Team 2']
      pp ft    = row['FT']
      pp ht    = row['HT']

      ## find round by pos
      if round
        handle_round( round )
        handle_game( date, team1, team2, ft, ht )
      else
        fail "round required for import; sorry"
      end
    end

    ###########################
    # backtrack and patch round dates (start_at/end_at)

    unless @patch_round_ids.empty?
      ###
      # note: use uniq - to allow multiple round headers (possible?)

      Round.find( @patch_round_ids.uniq ).each do |r|
        logger.debug "patch round start_at/end_at date for #{r.title}:"

        ## note:
        ## will add "scope" pos first e.g
        #
        ## SELECT "games".* FROM "games"  WHERE "games"."round_id" = ?
        # ORDER BY pos, play_at asc  [["round_id", 7]]
        #   thus will NOT order by play_at but by pos first!!!
        # =>
        #  need to unscope pos!!! or use unordered_games - games_by_play_at_date etc.??
        #   thus use reorder()!!! - not just order('play_at asc')

        games = r.games.reorder( 'play_at asc' ).all

        ## skip rounds w/ no games

        ## todo/check/fix: what's the best way for checking assoc w/ 0 recs?
        next if games.size == 0

        # note: make sure start_at/end_at is date only (e.g. use play_at.to_date)
        #   sqlite3 saves datetime in date field as datetime, for example (will break date compares later!)

        round_attribs = {
          start_at: games[0].play_at.to_date,   # use games.first ?
          end_at:   games[-1].play_at.to_date  # use games.last ? why? why not?
        }

        logger.debug round_attribs.to_json
        r.update_attributes!( round_attribs )
      end
    end
  end # method parse_fixtures

end # class CsvGameReader
end # module SportDb


module SportDB

class Loader

## make models available in sportdb module by default with namespace
#  e.g. lets you use Team instead of Models::Team
  include SportDB::Models

  def initialize( logger=nil )
    if logger.nil?
      @logger = Logger.new(STDOUT)
      @logger.level = Logger::INFO
    else
      @logger = logger
    end
  end

  attr_reader :logger

  def run( opts, args )

    args.each do |arg|
      name = arg     # File.basename( arg, '.*' )

      if opts.load?
        load_fixtures_builtin( name )
      else
        load_fixtures_with_include_path( name, opts.data_path )
      end
    end

  end # method run

  def load_fixtures_with_include_path( name, include_path )  # load from file system
    path = "#{include_path}/#{name}.rb"

    puts "*** loading data '#{name}' (#{path})..."

    ## nb: assume/enfore utf-8 encoding (with or without BOM - byte order mark)
    ## - see sportdb/utils.rb
    code = File.read_utf8( path )

    load_fixtures_worker( code )

    Prop.create!( key: "db.#{fixture_name_to_prop_key(name)}.version", value: "file.rb.#{File.mtime(path).strftime('%Y.%m.%d')}" )
  end

  def load_fixtures_builtin( name ) # load from gem (built-in)
    path = "#{SportDB.data_path}/#{name}.rb"

    puts "*** loading data '#{name}' (#{path})..."

    code = File.read_utf8( path )

    load_fixtures_worker( code )

    ## for builtin fixtures use VERSION of gem

    Prop.create!( key: "db.#{fixture_name_to_prop_key(name)}.version", value: "sport.rb.#{SportDB::VERSION}" )
  end

private

  def load_fixtures_worker( code )

    self.class_eval( code )

    # NB: same as
    #
    # module SportDB
    #   include SportDB::Models
    #  <code here>
    # end
  end

end # class Loader
end # module SportDB


# encoding: utf-8

module SportDb
  module FixtureHelpers

  def is_round_def?( line )
    ## must include bar (|) marker (make required)
    line =~ /\|/ && is_round?( line )
  end

  def is_round?( line )
    ## note: =~ return nil if not match found, and 0,1, etc for match
    (line =~ SportDb.lang.regex_round) != nil
  end

  def is_knockout_round?( line )

    ## todo: check for adding ignore case for regex (e.g. 1st leg/1st Leg)

    if line =~ SportDb.lang.regex_leg1
      logger.debug "  two leg knockout; skip knockout flag on first leg"
      false
    elsif line =~ SportDb.lang.regex_knockout_round
      logger.debug "   setting knockout flag to true"
      true
    elsif line =~ /K\.O\.|K\.o\.|Knockout/
        ## NB: add two language independent markers, that is, K.O. and Knockout
      logger.debug "   setting knockout flag to true (lang independent marker)"
      true
    else
      false
    end
  end

  ##
  ## fix/todo: check that [ROUND.TITLE2] and friends do NOT use pipes (|)
  ##  change all pipes (|) to dot (.)
  ## - pipes get used for def markers!!!

  def find_round_header_title2!( line )
    ## todo/fix:
    ##  cleanup method
    ##   use  buf.index( '//' ) to split string (see found_round_def)
    ##     why? simpler why not?
    ##  - do we currently allow groups if title2 present? add example if it works?

    # assume everything after // is title2 - strip off leading n trailing whitespaces
    regex = /\/{2,}\s*(.+)\s*$/
    if line =~ regex
      logger.debug "   title2: >#{$1}<"

      line.sub!( regex, '[ROUND.TITLE2]' )
      return $1
    else
      return nil    # no round title2 found (title2 is optional)
    end
  end

  def find_round_header_title!( line )
    # assume everything left is the round title
    #  extract all other items first (round title2, round pos, group title n pos, etc.)

    ## todo/fix:
    ##  cleanup method
    ##   use  buf.index( '//' ) to split string (see found_round_def)
    ##     why? simpler why not?
    ##  - do we currently allow groups if title2 present? add example if it works?

    buf = line.dup
    logger.debug "  find_round_header_title! line-before: >>#{buf}<<"

    buf.gsub!( /\[[^\]]+\]/, '' )   # e.g. remove [ROUND.POS], [ROUND.TITLE2], [GROUP.TITLE+POS] etc.
    buf.sub!( /\s+[\/\-]{1,}\s+$/, '' )    # remove optional trailing / or / chars (left over from group)
    buf.strip!    # remove leading and trailing whitespace

    logger.debug "  find_round_title! line-after: >>#{buf}<<"

    ### bingo - assume what's left is the round title

    logger.debug "   title: >>#{buf}<<"
    line.sub!( buf, '[ROUND.TITLE]' )

    buf
  end

  def find_round_def_title!( line )
    # assume everything before pipe (\) is the round title
    #  strip [ROUND.POS],  todo:?? [ROUND.TITLE2]

    # todo/fix: add title2 w/  // or /  why? why not?
    #  -- strip / or / chars

    buf = line.dup
    logger.debug "  find_round_def_title! line-before: >>#{buf}<<"

    ## cut-off everything after (including) pipe (|)
    buf = buf[ 0...buf.index('|') ]

    # e.g. remove [ROUND.POS], [ROUND.TITLE2], [GROUP.TITLE+POS] etc.
    buf.gsub!( /\[[^\]]+\]/, '' )    ## fix: use helper for (re)use e.g. remove_match_placeholder/marker or similar?
    # remove leading and trailing whitespace
    buf.strip!

    logger.debug "  find_round_def_title! line-after: >>#{buf}<<"

    logger.debug "   title: >>#{buf}<<"
    line.sub!( buf, '[ROUND.TITLE]' )

    buf
  end

  def find_round_pos!( line )
    # pass #1) extract optional round pos from line
    # e.g.  (1)   - must start line
    regex_pos = /^[ \t]*\((\d{1,3})\)[ \t]+/

    # pass #2) find free standing number  e.g. Matchday 3 or Round 5 or 3. Spieltag etc.
    # note: /\b(\d{1,3})\b/
    #   will match -12
    #  thus, use space required - will NOT match  -2 e.g. Group-2 Play-off
    #  note:  allow  1. Runde  n
    #                1^ Giornata
    regex_num = /(?:^|\s)(\d{1,3})(?:[.\^\s]|$)/

    if line =~ regex_pos
      logger.debug "   pos: >#{$1}<"

      line.sub!( regex_pos, '[ROUND.POS] ' )  ## NB: add back trailing space that got swallowed w/ regex -> [ \t]+
      return $1.to_i
    elsif line =~ regex_num
      ## assume number in title is pos (e.g. Jornada 3, 3 Runde etc.)
      ## NB: do NOT remove pos from string (will get removed by round title)

      num = $1.to_i  # note: clone capture; keep a copy (another regex follows; will redefine $1)

      #### fix:
      #  use/make keywords required
      #  e.g. Round of 16  -> should NOT match 16!
      #    Spiel um Platz 3  (or 5) etc -> should NOT match 3!
      #  Round 16 - ok
      #  thus, check for required keywords

      ## quick hack for round of 16
      # todo: mask match e.g. Round of xxx ... and try again - might include something
      #  reuse pattern for Group XX Replays for example
      if line =~ /^\s*Round of \d{1,3}\b/
         return nil
      end

      logger.debug "   pos: >#{num}<"
      return num
    else
      ## fix: add logger.warn no round pos found in line
      return nil
    end
  end # method find_round_pos!

  end # module FixtureHelpers
end # module SportDb


# encoding: UTF-8

module SportDb

class GameReader

  include LogUtils::Logging

## make models available by default with namespace
#  e.g. lets you use Usage instead of Model::Usage
  include Models

  attr_reader :event           # returns event record

## value helpers e.g. is_year?, is_taglist? etc.
  include TextUtils::ValueHelper

  include FixtureHelpers

  def self.from_zip( zip_file, entry_path, more_attribs={} )

    logger = LogKernel::Logger.root

    ## get text content from zip
    entry      = zip_file.find_entry( entry_path )
    event_text = entry.get_input_stream().read()
    event_text = event_text.force_encoding( Encoding::UTF_8 )

    ## hack:
    ##   support old event config format for now (will get removed later)
    ##   e.g. check for
    ##    teams:
    ##    12 teams:  etc.
    if event_text =~ /^teams:/i ||
       event_text =~ /^\d{1,2} teams:/i ||
       event_text =~ /^start_at:/i
      ## old format
      puts "*** [DEPRECATED] old event config format w/ yaml, please use new plain text format >#{entry_path}<"
      reader = EventReader.from_zip( zip_file, entry_path )
      reader.read()
    else
      ## new format
      reader = EventMetaReader.from_zip( zip_file, entry_path )
      reader.read()
    end

    event    = reader.event      ## was fetch_event( name )
    fixtures = reader.fixtures   ## was fetch_event_fixtures( name )

      ## add path to fixtures (use path from event e.g)
      #  - bl    + at-austria!/2012_13/bl  -> at-austria!/2012_13/bl
      #  - bl_ii + at-austria!/2012_13/bl  -> at-austria!/2012_13/bl_ii

      dir = File.dirname( entry_path ) # use dir for fixtures

      fixtures_with_path = fixtures.map do |fx|
        fx_new = "#{dir}/#{fx}.txt"   # add path upfront
        logger.debug "fx: #{fx_new} | >#{fx}< + >#{dir}<"
        fx_new
      end

    ## fix-fix-fix: change file extension to ??
    text_ary = []
    fixtures_with_path.each do |fixture_path|
      entry = zip_file.find_entry( fixture_path )

      text = entry.get_input_stream().read()
      text = text.force_encoding( Encoding::UTF_8 )

      text_ary << text
    end

    self.from_string( event, text_ary, more_attribs )
  end

  def self.from_file( path, more_attribs={} )

    logger = LogKernel::Logger.root

    ### NOTE: fix-fix-fix - pass in event path!!!!!!! (not fixture path!!!!)

    ## - ## note: assume/enfore utf-8 encoding (with or without BOM - byte order mark)
    ## - ## - see textutils/utils.rb
    ## - text = File.read_utf8( path )

    event_text = File.read_utf8( path )

    ## hack:
    ##   support old event config format for now (will get removed later)
    ##   e.g. check for
    ##    teams:
    ##    12 teams:  etc.
    if event_text =~ /^teams:/i ||
       event_text =~ /^\d{1,2} teams:/i ||
       event_text =~ /^start_at:/i

      ## old format
      puts "*** [DEPRECATED] old event config format w/ yaml, please use new plain text format >#{path}<"
      reader = EventReader.from_file( path )
      reader.read()
    else
      ## new format
      reader = EventMetaReader.from_file( path )
      reader.read()
    end

    event    = reader.event      ## was fetch_event( name )
    fixtures = reader.fixtures   ## was fetch_event_fixtures( name )

      ## add path to fixtures (use path from event e.g)
      #  - bl    + at-austria!/2012_13/bl  -> at-austria!/2012_13/bl
      #  - bl_ii + at-austria!/2012_13/bl  -> at-austria!/2012_13/bl_ii

      dir = File.dirname( path ) # use dir for fixtures

      fixtures_with_path = fixtures.map do |fx|
        fx_new = "#{dir}/#{fx}.txt"   # add path upfront
        logger.debug "fx: #{fx_new} | >#{fx}< + >#{dir}<"
        fx_new
      end

    ## fix-fix-fix: change file extension to ??
    text_ary = []
    fixtures_with_path.each do |fixture_path|
       text_ary << File.read_utf8( fixture_path )
    end

    self.from_string( event, text_ary, more_attribs )
  end

  def self.from_string( event, text_or_text_ary, more_attribs={} )
    ### fix - fix -fix:
    ##  change event to event_or_event_key !!!!!  - allow event_key as string passed in
    self.new( event, text_or_text_ary, more_attribs )
  end

  def initialize( event, text_or_text_ary, more_attribs={} )
    ### fix - fix -fix:
    ##  change event to event_or_event_key !!!!!  - allow event_key as string passed in

    ## todo/fix: how to add opts={} ???
    @event             = event
    @text_or_text_ary  = text_or_text_ary
    @more_attribs      = more_attribs
  end

  def read
    if @text_or_text_ary.is_a?( String )
      text_ary = [@text_or_text_ary]
    else
      text_ary = @text_or_text_ary
    end

    ## reset cached values
    ##  for auto-number rounds etc.
    @last_round_pos = nil

    text_ary.each do |text|
      ## assume en for now? why? why not?
      ##  fix (cache) store lang in event table (e.g. auto-add and auto-update)!!!
      SportDb.lang.lang = SportDb.lang.classify( text )

      reader = LineReader.from_string( text )

      read_fixtures_worker( @event.key, reader )
    end

    ## fix add prop ??
    ### Prop.create!( key: "db.#{fixture_name_to_prop_key(name)}.version", value: "file.txt.#{File.mtime(path).strftime('%Y.%m.%d')}" )
  end

  def read_fixtures_worker( event_key, reader )
    ## NB: assume active activerecord connection

    ## reset cached values
    @patch_round_ids_dates = []
    @patch_round_ids_pos   = []

    @round         = nil     ## fix: change/rename to @last_round !!!
    @group         = nil     ## fix: change/rename to @last_group !!!
    @last_date     = nil

    @last_team1    = nil    # used for goals (to match players via squads)
    @last_team2    = nil
    @last_game     = nil

    #####
    #  fix: move to read and share event/known_teams
    #    for all 1-n fixture files (no need to configure every time!!)

    @event = Event.find_by_key!( event_key )

    logger.debug "Event #{@event.key} >#{@event.title}<"

    ### fix: use build_title_table_for ??? why? why not??
    ## @known_teams  = @event.known_teams_table

    @mapper_teams = TeamMapper.new( @event.teams )

    @known_grounds  = TextUtils.build_title_table_for( @event.grounds )

    parse_fixtures( reader )

  end   # method load_fixtures

  def parse_group_header( line )
    logger.debug "parsing group header line: >#{line}<"

    # note: group header resets (last) round  (allows, for example):
    #  e.g.
    #  Group Playoffs/Replays       -- round header
    #    team1 team2                -- match
    #  Group B:                     -- group header
    #    team1 team2 - match  (will get new auto-matchday! not last round)
    @round         = nil     ## fix: change/rename to @last_round !!!

    title, pos = find_group_title_and_pos!( line )

    logger.debug "    title: >#{title}<"
    logger.debug "    pos: >#{pos}<"
    logger.debug "  line: >#{line}<"

    # set group for games
    @group = Group.find_by_event_id_and_pos!( @event.id, pos )
  end

  def parse_group_def( line )
    logger.debug "parsing group def line: >#{line}<"

    @mapper_teams.map_teams!( line )
    team_keys = @mapper_teams.find_teams!( line )

    title, pos = find_group_title_and_pos!( line )

    logger.debug "  line: >#{line}<"

    group_attribs = {
      title: title
    }

    group = Group.find_by_event_id_and_pos( @event.id, pos )
    if group.present?
      logger.debug "update group #{group.id}:"
    else
      logger.debug "create group:"
      group = Group.new
      group_attribs = group_attribs.merge( {
        event_id: @event.id,
        pos:   pos
      })
    end

    logger.debug group_attribs.to_json

    group.update_attributes!( group_attribs )

    group.teams.clear  # remove old teams
    ## add new teams
    team_keys.each do |team_key|
      team = Team.find_by_key!( team_key )
      logger.debug "  adding team #{team.title} (#{team.code})"
      group.teams << team
    end
  end

  def parse_round_def( line )
    logger.debug "parsing round def line: >#{line}<"

    ### todo/fix/check:  move cut off optional comment in reader for all lines? why? why not?
    cut_off_end_of_line_comment!( line )  # cut off optional comment starting w/ #

    start_at = find_date!( line, start_at: @event.start_at )
    end_at   = find_date!( line, start_at: @event.start_at )

    # note: if end_at missing -- assume start_at is (==) end_at
    end_at = start_at  if end_at.nil?

    # note: - NOT needed; start_at and end_at are saved as date only (NOT datetime)
    #  set hours,minutes,secs to beginning and end of day (do NOT use default 12.00)
    #   e.g. use 00.00 and 23.59
    # start_at = start_at.beginning_of_day
    # end_at   = end_at.end_of_day

    # note: make sure start_at/end_at is date only (e.g. use start_at.to_date)
    #   sqlite3 saves datetime in date field as datetime, for example (will break date compares later!)
    start_at = start_at.to_date
    end_at   = end_at.to_date

    pos   = find_round_pos!( line )
    title = find_round_def_title!( line )
    # NB: use extracted round title for knockout check
    knockout_flag = is_knockout_round?( title )

    logger.debug "    start_at: #{start_at}"
    logger.debug "    end_at:   #{end_at}"
    logger.debug "    pos:      #{pos}"
    logger.debug "    title:    >#{title}<"
    logger.debug "    knockout_flag:   #{knockout_flag}"

    logger.debug "  line: >#{line}<"

    #######################################
    # fix: add auto flag is false !!!!

    round_attribs = {
      title:    title,
      knockout: knockout_flag,
      start_at: start_at,
      end_at:   end_at
    }

    round = Round.find_by_event_id_and_pos( @event.id, pos )
    if round.present?
      logger.debug "update round #{round.id}:"
    else
      logger.debug "create round:"
      round = Round.new

      round_attribs = round_attribs.merge( {
        event_id: @event.id,
        pos:   pos
      })
    end

    logger.debug round_attribs.to_json

    round.update_attributes!( round_attribs )
  end

  def parse_round_header( line )
    logger.debug "parsing round header line: >#{line}<"

    ### todo/fix/check:  move cut off optional comment in reader for all lines? why? why not?
    cut_off_end_of_line_comment!( line )  # cut off optional comment starting w/ #

    # NB: cut off optional title2 starting w/  //  first
    title2 = find_round_header_title2!( line )

    # todo/fix: check if it is possible title2 w/ group?
    #  add an example here
    group_title, group_pos = find_group_title_and_pos!( line )

    ## todo/check/fix:
    #   make sure  Round of 16  will not return pos 16 -- how? possible?
    #   add unit test too to verify
    pos = find_round_pos!( line )

    ## check if pos available; if not auto-number/calculate
    if pos.nil?
      if @patch_round_ids_pos.empty?
        pos = (@last_round_pos||0)+1
        logger.debug( "  no round pos found; auto-number round - use (#{pos})" )
      else
        # note: if any rounds w/o pos already seen (add for auto-numbering at the end)
        #  will get auto-numbered sorted by start_at date
        pos = 999001+@patch_round_ids_pos.length   # e.g. 999<count> - 999001,999002,etc.
        logger.debug( "  no round pos found; auto-number round w/ patch (backtrack) at the end" )
      end
    end

    # store pos for auto-number next round if missing
    #  - note: only if greater/bigger than last; use max
    #  - note: last_round_pos might be nil - thus set to 0
    if pos > 999000
      # note: do NOT update last_round_pos for to-be-patched rounds
    else
      @last_round_pos = [pos,@last_round_pos||0].max
    end

    title = find_round_header_title!( line )

    ## NB: use extracted round title for knockout check
    knockout_flag = is_knockout_round?( title )

    if group_pos.present?
      @group = Group.find_by_event_id_and_pos!( @event.id, group_pos )
    else
      @group = nil   # reset group to no group
    end

    logger.debug "  line: >#{line}<"

    ## NB: dummy/placeholder start_at, end_at date
    ##  replace/patch after adding all games for round

    round_attribs = {
      title:  title,
      title2: title2,
      knockout: knockout_flag
    }

    if pos > 999000
      # no pos (e.g. will get autonumbered later) - try match by title for now
      #  e.g. lets us use title 'Group Replays', for example, multiple times
      @round = Round.find_by_event_id_and_title( @event.id, title )
    else
      @round = Round.find_by_event_id_and_pos( @event.id, pos )
    end

    if @round.present?
      logger.debug "update round #{@round.id}:"
    else
      logger.debug "create round:"
      @round = Round.new

      round_attribs = round_attribs.merge( {
        event_id: @event.id,
        pos:   pos,
        start_at: Date.parse('1911-11-11'),
        end_at:   Date.parse('1911-11-11')
      })
    end

    logger.debug round_attribs.to_json

    @round.update_attributes!( round_attribs )

    @patch_round_ids_pos   << @round.id    if pos > 999000
    ### store list of round ids for patching start_at/end_at at the end
    @patch_round_ids_dates << @round.id   # todo/fix/check: check if round has definition (do NOT patch if definition (not auto-added) present)
  end

  def try_parse_game( line )
    # note: clone line; for possible test do NOT modify in place for now
    # note: returns true if parsed, false if no match
    parse_game( line.dup )
  end

  def parse_game( line )
    logger.debug "parsing game (fixture) line: >#{line}<"

    @mapper_teams.map_teams!( line )   ### todo/fix: limit mapping to two(2) teams - why? why not?  might avoid matching @ Barcelona ??
    team_keys = @mapper_teams.find_teams!( line )
    team1_key = team_keys[0]
    team2_key = team_keys[1]

    ## note: if we do NOT find two teams; return false - no match found
    if team1_key.nil? || team2_key.nil?
      logger.debug "  no game match (two teams required) found for line: >#{line}<"
      return false
    end

    pos = find_game_pos!( line )

    if is_postponed?( line )
      postponed  = true
      date_v2    = find_date!( line, start_at: @event.start_at )
      date       = find_date!( line, start_at: @event.start_at )
    else
      postponed = false
      date_v2   = nil
      date      = find_date!( line, start_at: @event.start_at )
    end

    ###
    # check if date found?
    #   NB: ruby falsey is nil & false only (not 0 or empty array etc.)
    if date
      ### check: use date_v2 if present? why? why not?
      @last_date = date    # keep a reference for later use
    else
      date = @last_date    # no date found; (re)use last seen date
    end

    scores = find_scores!( line )

    ####
    # note:
    #  only map ground if we got any grounds (setup/configured in event)

    if @event.grounds.count > 0

     ## todo/check: use @known_grounds for check?? why? why not??
     ## use in @known_grounds  = TextUtils.build_title_table_for( @event.grounds )

     ##
     # fix: mark mapped title w/ type (ground-) or such!! - too avoid fallthrough match
     #  e.g. three teams match - but only two get mapped, third team gets match for ground
     #    e.g Somalia v Djibouti  @ Djibouti
      map_ground!( line )
      ground_key = find_ground!( line )
      ground =  ground_key.nil? ? nil : Ground.find_by_key!( ground_key )
    else
      # no grounds configured; always nil
      ground = nil
    end

    logger.debug "  line: >#{line}<"

    ### todo: cache team lookups in hash?

    team1 = Team.find_by_key!( team1_key )
    team2 = Team.find_by_key!( team2_key )

    @last_team1 = team1    # store for later use for goals etc.
    @last_team2 = team2

    if @round.nil?
      ## no round header found; calculate round from date

      ###
      ## todo/fix: add some unit tests for round look up
      #  fix: use date_v2 if present!! (old/original date; otherwise use date)

      #
      # fix: check - what to do with hours e.g. start_at use 00:00 and for end_at use 23.59 ??
      #  -- for now - remove hours (e.g. use end_of_day and beginnig_of_day)

      ##
      # note: start_at and end_at are dates ONLY (note datetime)
      #  - do NOT pass in hours etc. in query
      #  again use -->  date.end_of_day, date.beginning_of_day
      #  new: not working:  date.to_date, date.to_date
      #    will not find round if  start_at same as date !! (in theory hours do not matter)

      ###
      # hack:
      #  special case for sqlite3 (date compare not working reliable; use casts)
      #  fix: move to  adapter_name to activerecord_utils as sqlite? or similar?

      if ActiveRecord::Base.connection.adapter_name.downcase.starts_with?( 'sqlite' )
        logger.debug( "  [sqlite] using sqlite-specific query for date compare for rounds finder" )
        round = Round.where( 'event_id = ? AND (    julianday(start_at) <= julianday(?)'+
                                               'AND julianday(end_at)   >= julianday(?))',
                               @event.id, date.to_date, date.to_date).first
      else  # all other dbs (postgresql, mysql, etc.)
        round = Round.where( 'event_id = ? AND (start_at <= ? AND end_at >= ?)',
                             @event.id, date.to_date, date.to_date).first
      end

      pp round
      if round.nil?
        logger.warn( "  !!!! no round match found for date #{date}" )
        pp Round.all

        ###################################
        # -- try auto-adding matchday
        round = Round.new

        round_attribs = {
          event_id: @event.id,
          title: "Matchday #{date.to_date}",
          pos: 999001+@patch_round_ids_pos.length,   # e.g. 999<count> - 999001,999002,etc.
          start_at:  date.to_date,
          end_at:    date.to_date
        }

        logger.info( "  auto-add round >Matchday #{date.to_date}<" )
        logger.debug round_attribs.to_json

        round.update_attributes!( round_attribs )

        @patch_round_ids_pos << round.id   # todo/check - add just id or "full" record as now - why? why not?
      end

      # store pos for auto-number next round if missing
      #  - note: only if greater/bigger than last; use max
      #  - note: last_round_pos might be nil - thus set to 0
      if round.pos > 999000
        # note: do NOT update last_round_pos for to-be-patched rounds
      else
        @last_round_pos = [round.pos,@last_round_pos||0].max
      end

      ## note: will crash (round.pos) if round is nil
      logger.debug( "  using round #{round.pos} >#{round.title}< start_at: #{round.start_at}, end_at: #{round.end_at}" )
    else
      ## use round from last round header
      round = @round
    end

    ### check if games exists
    ##  with this teams in this round if yes only update
    game = Game.find_by_round_id_and_team1_id_and_team2_id(
                         round.id, team1.id, team2.id
    )

    game_attribs = {
      score1i:   scores[0],
      score2i:   scores[1],
      score1:    scores[2],
      score2:    scores[3],
      score1et:  scores[4],
      score2et:  scores[5],
      score1p:   scores[6],
      score2p:   scores[7],
      play_at:    date,
      play_at_v2: date_v2,
      postponed: postponed,
      knockout:  round.knockout,   ## note: for now always use knockout flag from round - why? why not??
      ground_id: ground.present? ? ground.id : nil,
      group_id:  @group.present? ? @group.id : nil
    }

    game_attribs[ :pos ] = pos   if pos.present?

    ####
    # note: only update if any changes (or create if new record)
    if game.present? &&
       game.check_for_changes( game_attribs ) == false
          logger.debug "  skip update game #{game.id}; no changes found"
    else
      if game.present?
        logger.debug "update game #{game.id}:"
      else
        logger.debug "create game:"
        game = Game.new

        more_game_attribs = {
          round_id: round.id,
          team1_id: team1.id,
          team2_id: team2.id
        }

        ## NB: use round.games.count for pos
        ##  lets us add games out of order if later needed
        more_game_attribs[ :pos ] = round.games.count+1   if pos.nil?

        game_attribs = game_attribs.merge( more_game_attribs )
      end

      logger.debug game_attribs.to_json
      game.update_attributes!( game_attribs )
    end

    @last_game = game   # store for later reference (e.g. used for goals etc.)

    return true   # game match found
  end # method parse_game

  def try_parse_date_header( line )
    # note: clone line; for possible test do NOT modify in place for now
    # note: returns true if parsed, false if no match
    parse_date_header( line.dup )
  end

  def parse_date_header( line )
    # note: returns true if parsed, false if no match

    # line with NO teams  plus include date e.g.
    #   [Fri Jun/17]  or
    #   Jun/17  or
    #   Jun/17:   etc.

    @mapper_teams.map_teams!( line )
    team_keys = @mapper_teams.find_teams!( line )
    team1_key = team_keys[0]
    team2_key = team_keys[1]

    date  = find_date!( line, start_at: @event.start_at )

    if date && team1_key.nil? && team2_key.nil?
      logger.debug( "date header line found: >#{line}<")
      logger.debug( "    date: #{date}")

      @last_date = date   # keep a reference for later use
      return true
    else
      return false
    end
  end

  def parse_goals( line )
    logger.debug "parsing goals (fixture) line: >#{line}<"

    goals = GoalsFinder.new.find!( line )

    ## check if squads/rosters present for player mappings
    #
    squad1_count = Roster.where( event_id: @event.id, team_id: @last_team1 ).count
    if squad1_count > 0
       squad1 = Roster.where( event_id: @event.id, team_id: @last_team1 )
    else
       squad1 = []
    end

    squad2_count = Roster.where( event_id: @event.id, team_id: @last_team2 ).count
    if squad2_count > 0
      squad2 = Roster.where( event_id: @event.id, team_id: @last_team2 )
    else
      squad2 = []
    end

    #####
    # todo/fix: try lookup by squads first!!!
    #   issue warning if player not included in squad!!

    ##########
    # try mapping player names to player

    ## note: first delete all goals for match (and recreate new ones
    #     no need to figure out update/merge strategy)
    @last_game.goals.delete_all

    goals.each do |goal|
      player_name = goal.name

      player = Person.where( name: player_name ).first
      if player
        logger.info "  player match (name eq) - using player key #{player.key}"
      else
        # try like match (player name might only include part of name e.g. Messi)
        #  try three variants
        # try %Messi
        # try Messi%
        # try %Messi%   -- check if there's an easier way w/ "one" where clause?
        player = Person.where( 'name LIKE ? OR name LIKE ? OR name LIKE ?',
                              "%#{player_name}",
                              "#{player_name}%",
                              "%#{player_name}%"
                             ).first

        if player
          logger.info "  player match (name like) - using player key #{player.key}"
        else
           # try synonyms
           player = Person.where( 'synonyms LIKE ? OR synonyms LIKE ? OR synonyms LIKE ?',
                              "%#{player_name}",
                              "#{player_name}%",
                              "%#{player_name}%"
                             ).first
           if player
             logger.info "  player match (synonyms like) - using player key #{player.key}"
           else
             # auto-create player (player not found)
             logger.info "  player NOT found >#{player_name}< - auto-create"

             ## fix: add auto flag (for auto-created persons/players)
             ## fix: move title_to_key logic to person model etc.
             player_key = TextUtils.title_to_key( player_name )
             player_attribs = {
               key:   player_key,
               title: player_name
             }
             logger.info "   using attribs: #{player_attribs.inspect}"

             player = Person.create!( player_attribs )
           end
        end
      end

      goal_attribs = {
        game_id:   @last_game.id,
        team_id:   goal.team == 1 ? @last_team1.id : @last_team2.id,
        person_id: player.id,
        minute:    goal.minute,
        offset:    goal.offset,
        penalty:   goal.penalty,
        owngoal:   goal.owngoal,
        score1:    goal.score1,
        score2:    goal.score2
      }

      logger.info "  adding goal using attribs: #{goal_attribs.inspect}"
      Goal.create!( goal_attribs )
    end # each goals

  end # method parse_goals

=begin
###### add to person and use!!!
def self.create_or_update_from_values( values, more_attribs={} )
    ## key & title required

    attribs, more_values = find_key_n_title( values )
    attribs = attribs.merge( more_attribs )

    ## check for optional values
    Person.create_or_update_from_attribs( attribs, more_values )
  end
=end

  def parse_fixtures( reader )

    reader.each_line do |line|

      if is_goals?( line )
        parse_goals( line )
      elsif is_round_def?( line )
        ## todo/fix:  add round definition (w begin n end date)
        ## todo: do not patch rounds with definition (already assume begin/end date is good)
        ##  -- how to deal with matches that get rescheduled/postponed?
        parse_round_def( line )
      elsif is_round?( line )
        parse_round_header( line )
      elsif is_group_def?( line ) ## NB: group goes after round (round may contain group marker too)
        ### todo: add pipe (|) marker (required)
        parse_group_def( line )
      elsif is_group?( line )
        ##  -- lets you set group  e.g. Group A etc.
        parse_group_header( line )
      elsif try_parse_game( line )
        # do nothing here
      elsif try_parse_date_header( line )
        # do nothing here
      else
        logger.info "skipping line (no match found): >#{line}<"
      end
    end # lines.each

    ###########################
    # backtrack and patch round pos and round dates (start_at/end_at)
    #  note: patch dates must go first! (otherwise sort_by_date will not work for round pos)

    unless @patch_round_ids_dates.empty?
      ###
      #  fix: do NOT patch if auto flag is set to false !!!
      #   e.g. rounds got added w/ round def (not w/ round header)

      # note: use uniq - to allow multiple round headers (possible?)

      Round.find( @patch_round_ids_dates.uniq ).each do |r|
        logger.debug "patch round start_at/end_at date for #{r.title}:"

        ## note:
        ## will add "scope" pos first e.g
        #
        ## SELECT "games".* FROM "games"  WHERE "games"."round_id" = ?
        # ORDER BY pos, play_at asc  [["round_id", 7]]
        #   thus will NOT order by play_at but by pos first!!!
        # =>
        #  need to unscope pos!!! or use unordered_games - games_by_play_at_date etc.??
        #   thus use reorder()!!! - not just order('play_at asc')

        games = r.games.reorder( 'play_at asc' ).all

        ## skip rounds w/ no games

        ## todo/check/fix: what's the best way for checking assoc w/ 0 recs?
        next if games.size == 0

        # note: make sure start_at/end_at is date only (e.g. use play_at.to_date)
        #   sqlite3 saves datetime in date field as datetime, for example (will break date compares later!)

        round_attribs = {
          start_at: games[0].play_at.to_date,   # use games.first ?
          end_at:   games[-1].play_at.to_date  # use games.last ? why? why not?
        }

        logger.debug round_attribs.to_json
        r.update_attributes!( round_attribs )
      end
    end

    unless @patch_round_ids_pos.empty?

      # step 0: check for offset (last_round_pos)
      if @last_round_pos
        offset = @last_round_pos
        logger.info "  +++ patch round pos - use offset; start w/ #{offset}"
      else
        offset = 0
        logger.debug "  patch round pos - no offset; start w/ 0"
      end

      # step 1: sort by date
      # step 2: update pos
      # note: use uniq - to allow multiple round headers (possible?)
      Round.order( 'start_at asc').find( @patch_round_ids_pos.uniq ).each_with_index do |r,idx|
        # note: starts counting w/ zero(0)
        logger.debug "[#{idx+1}] patch round pos >#{offset+idx+1}< for #{r.title}:"
        round_attribs = {
          pos: offset+idx+1
        }

        # update title if Matchday XXXX  e.g. use Matchday 1 etc.
        if r.title.starts_with?('Matchday')
          round_attribs[:title] = "Matchday #{offset+idx+1}"
        end

        logger.debug round_attribs.to_json
        r.update_attributes!( round_attribs )

        # update last_round_pos offset too
        @last_round_pos = [offset+idx+1,@last_round_pos||0].max
      end
    end

  end # method parse_fixtures

end # class GameReader
end # module SportDb


# encoding: utf-8

module SportDb

class GoalsPlayerStruct
  ##
  # note: player with own goal (o.g) gets listed on other team
  #  (thus, player might have two entries if also scored for its own team)
  #
  attr_accessor :name
  attr_accessor :minutes   # ary of minutes e.g. 30', 45+2', 72'

  def initialize
    @minutes = []
  end
end

class GoalsMinuteStruct
  attr_accessor :minute, :offset
  attr_accessor :penalty, :owngoal    # flags

  def initialize
    @offset  = 0
    @penalty = false
    @owngoal = false
  end
end

class GoalStruct
  ######
  # flat struct for goals - one entry per goals
  attr_accessor :name
  attr_accessor :team   #  1 or 2 ? check/todo: add team1 or team2 flag?
  attr_accessor :minute, :offset
  attr_accessor :penalty, :owngoal
  attr_accessor :score1, :score2  # gets calculated

  ## add pos  for sequence number? e.g. 1,2,3,4  (1st goald, 2nd goal, etc.) ???

  def initialize
    # do nothing
  end
end

# todo: find a better name? to avoid confusing w/ GoalsParser? use MatchGoalsParser or similar?
class GoalsFinder
  include LogUtils::Logging
  include FixtureHelpers   # e.g. cut_off_end_of_line_comment!

  def initialize
    # nothing here for now
  end

  def find!( line, opts={} )
    # remove end-of-line comments
    #   - move to textutils ?? why? why not??
    cut_off_end_of_line_comment!( line )     ## note: func defined in utils.rb (FixtureHelpers)

    # remove [] if presents e.g. [Neymar 12']
    line = line.gsub( /[\[\]]/, '' )
    # remove (single match) if line starts w/ - (allow spaces)  e.g. [-;Neymar 12'] or [ - ;Neymar 12']
    line = line.sub( /^[ ]*-[ ]*/, '' )

    # split into left hand side (lhs) for team1 and
    #            right hand side (rhs) for team2

    values = line.split( ';' )

    # note: allow empty right hand side (e.g. team2 did NOT score any goals e.g. 3-0 etc.)
    lhs = values[0]
    rhs = values[1]

    lhs = lhs.strip   unless lhs.nil?
    rhs = rhs.strip   unless rhs.nil?

    parser = GoalsParser.new
    ## todo/check: only call if not nil?

    logger.debug "  lhs (team1): >#{lhs}<"
    lhs_data = parser.parse!( lhs )
    pp lhs_data

    logger.debug "  rhs (team2): >#{rhs}<"
    rhs_data = parser.parse!( rhs )
    pp rhs_data

    ### merge into flat goal structs
    goals = []
    lhs_data.each do |player|
      player.minutes.each do |minute|
        goal = GoalStruct.new
        goal.name    = player.name
        goal.team    = 1
        goal.minute  = minute.minute
        goal.offset  = minute.offset
        goal.penalty = minute.penalty
        goal.owngoal = minute.owngoal
        goals << goal
      end
    end

    rhs_data.each do |player|
      player.minutes.each do |minute|
        goal = GoalStruct.new
        goal.name    = player.name
        goal.team    = 2
        goal.minute  = minute.minute
        goal.offset  = minute.offset
        goal.penalty = minute.penalty
        goal.owngoal = minute.owngoal
        goals << goal
      end
    end

    # sort by minute + offset
    goals = goals.sort do |l,r|
      res = l.minute <=> r.minute
      if res == 0
        res =  l.offset <=> r.offset  # pass 2: sort by offset
      end
      res
    end

    ## calc score1,score2
    score1 = 0
    score2 = 0
    goals.each do |goal|
      if goal.team == 1
        score1 += 1
      elsif goal.team == 2
        score2 += 1
      else
        # todo: should not happen: issue warning
      end
      goal.score1 = score1
      goal.score2 = score2
    end

    logger.debug "  #{goals.size} goals:"
    pp goals

    goals
  end

end  # class GoalsFinder

class GoalsParser
  include LogUtils::Logging

  # note: use ^ for start of string only!!!
  # - for now slurp everything up to digits (inlc. spaces - use strip to remove)
  # todo/check: use/rename to NAME_UNTIL_REGEX ??? ( add lookahead for spaces?)
  NAME_REGEX = /^
                [^0-9]+
               /x

  # todo/check: change to MINUTE_REGEX ??
  # add MINUTE_SKIP_REGEX or MINUTE_SEP_REGEX /^[ ,]+/
  # todo/fix:  split out  penalty and owngoal flag in PATTERN constant for reuse
  MINUTES_REGEX = /^      # note: use ^ for start of string only!!!
                    (?<minute>[0-9]{1,3})
                    (?:\+
                      (?<offset>[1-9]{1})
                    )?
                    '
                    (?:[ ]*
                      \(
                      (?<type>P|pen\.|o\.g\.)
                      \)
                    )?
                  /x

  def initialize
    # nothing here for now
  end

  def parse!( line, opts={} )

    ## for now assume
    ##    everything up-to  0-9 and , and () is part of player name

    ## try parsing lhs
    ##  todo: check for  empty -    remove (make it same as empty string)

    players = []

    name = get_player_name!( line )
    while name
      logger.debug "  found player name >#{name}< - remaining >#{line}<"

      player = GoalsPlayerStruct.new
      player.name = name

      minute_hash = get_minute_hash!( line )
      while minute_hash
        logger.debug "  found minutes >#{minute_hash.inspect}< - remaining >#{line}<"

        minute = GoalsMinuteStruct.new
        minute.minute = minute_hash[:minute].to_i
        minute.offset = minute_hash[:offset].to_i  if minute_hash[:offset]
        if minute_hash[:type]
          minute.owngoal = true  if minute_hash[:type] =~ /o\.g\./
          minute.penalty = true  if minute_hash[:type] =~ /P|pen\./
        end
        player.minutes << minute

        # remove commas and spaces (note: use ^ for start of string only!!!)
        line.sub!( /^[ ,]+/, '' )
        minute_hash = get_minute_hash!( line )
      end

      players << player
      name = get_player_name!( line )
    end

    players
  end   # method parse!

private
  def get_player_name!( line )
    m = NAME_REGEX.match( line )
    if m
      ## remove from line
      line.slice!( 0...m[0].length )
      m[0].strip    # remove leading and trailing spaces
    else
      nil
    end
  end

  def get_minute_hash!( line )
    m = MINUTES_REGEX.match( line ) # note: use ^ for start of string only!!!
    if m
      h = {}
      # - note: do NOT forget to turn name into symbol for lookup in new hash (name.to_sym)
      m.names.each { |n| h[n.to_sym] = m[n] } # or use match_data.names.zip( match_data.captures ) - more cryptic but "elegant"??

      ## remove matched string from line
      line.slice!( 0...m[0].length )
      h
    else
      nil
    end
  end

end # class GoalsParser

end # module SportDb


# encoding: UTF-8

module SportDb

###
## reads in a text files
##   and tries to fix up/patch some parts

class Patcher

  include LogUtils::Logging

  def initialize( root, opts={} )
    @root  = root
    @path  = opts[:path] || opts[:paths]    # pass in regex as string
    @names = opts[:name] || opts[:names]    # pass in regex as string

    @path_regex  = Regexp.new( @path )  if @path
    @names_regex = Regexp.new( @names ) if @names
  end

  def find_files
    ## note: for now always filter by .txt extension
    files = Dir[ "#{@root}/**/*.txt" ]

    puts "before filter:"
    pp files

    files = files.select do |f|

      basename = File.basename( f )
      dirname  = File.dirname( f )   # note: add trailing slash
      dirname = "#{dirname}/"

      puts "  basename=>#{basename}<, dirname=>#{dirname}<"

      if @path_regex
        match_dirname =  @path_regex === dirname    # note: use === (triple) for true/false regex match
      else
        match_dirname = true
      end

      if @names_regex
        match_basename = @names_regex === basename
      else
        match_basename = true
      end

      match_basename && match_dirname
    end

    puts "after filter:"
    pp files

    files
  end # method find_files

  def patch( save=false)
    files = find_files
    change_logs = []

    files.each do |file|
      p = PrettyPrinter.from_file( file )
      new_text, change_log = p.patch

      next if change_log.empty?   ## no changes

      if save
        File.open( file, 'w' ) do |f|
          f.write new_text
        end
      end

      change_logs << [file, change_log]
    end

    change_logs  ## return change_logs or empty array
  end # method patch

end  ## class Patcher

class PrettyPrinter

  include LogUtils::Logging

  def self.from_file( path, opts={} )
    ## note: assume/enfore utf-8 encoding (with or without BOM - byte order mark)
    ## - see textutils/utils.rb
    text = File.read_utf8( path )
    self.from_string( text, opts )
  end

  def self.from_string( text, opts={} )
    self.new( text, opts )
  end

  def initialize( text, opts={} )
    @old_text = text
    @opts     = opts
  end

  ## change to DE_SCORES_REGEX or SCORES_DE_REGEX ??
  SCORES_REGEX = /\b
                  (?<score1>\d{1,2})
                    -
                  (?<score2>\d{1,2})
                  \b
                 /x

  def patch  ## return new text (and change log??)
    new_text = ''
    change_log = []   ## track changes  - rename - use edits? changes? etc.
    line_no = 0

    @old_text.each_line do |line|
      line_no +=1

      # comments allow:
      # 1) #####  (shell/ruby style)
      # 2) --  comment here (haskel/?? style)
      # 3) % comment here (tex/latex style)

      if line =~ /^\s*#/ || line =~ /^\s*--/ || line =~ /^\s*%/
        # skip komments and do NOT copy to result (keep comments secret!)
        logger.debug "[#{line_no}] add comment line >#{line}<"
        new_text << line
        next
      end

      if line =~ /^\s*$/
        # kommentar oder leerzeile überspringen
        logger.debug "[#{line_no}] add blank line >#{line}<"
        new_text << line
        next
      end

      ## do nothing for now
      if line =~ SCORES_REGEX
        ## patch scores format
        logger.debug "** found scores >#{line}<"

        new_line = line.dup
        new_line.gsub!( SCORES_REGEX ) do |m|
          logger.debug " match: >#{m}<  : #{m.class.name}"

          old_scores = m
          ## todo: how to use named captures? possible?
          new_scores = "#{$1}:#{$2}"

          change_log << [line_no, "changed >#{old_scores}< to >#{new_scores}< in >#{line}<"]

          new_scores
        end

        logger.debug "[#{line_no}] add patched line >#{new_line}<"
        new_text << new_line
      else
        logger.debug "[#{line_no}] add >#{line}<"
        new_text << line
      end
    end # each lines

    [new_text, change_log]
  end # method patch

end # class PrettyPrinter
end # module SportDb


# encoding: utf-8

module SportDb

class ReaderBase

  include LogUtils::Logging

## make models available in sportdb module by default with namespace
#  e.g. lets you use Team instead of Model::Team
  include SportDb::Models
  include SportDb::Matcher # lets us use match_teams_for_country etc.

  def load_setup( name )
    reader = create_fixture_reader( name )

    reader.each do |fixture_name|
      load( fixture_name )
    end
  end # method load_setup

  def load( name )   # convenience helper for all-in-one reader

    logger.debug "enter load( name=>>#{name}<<)"    ## formerly also printed -> include_path=>>#{include_path}<<

    if match_players_for_country( name ) do |country_key|
            ## country = Country.find_by_key!( country_key )
            ## fix-fix-fix-fix-fix-fix: change to new format e.g. from_file, from_zip etc!!!
            ## reader = PersonDb::PersonReader.new( include_path )
            ## reader.read( name, country_id: country.id )
          end
    elsif name =~ /\/squads\/([a-z0-9]{3,})$/    # e.g. ajax.txt bayern.txt etc.
      ## note: for now assume club (e.g. no dash (-) allowed for country code e.g. br-brazil etc.)
      team = Team.find_by_key!( $1 )
      ## note: pass in @event.id - that is, last seen event (e.g. parsed via GameReader/MatchReader)
      reader = create_club_squad_reader( name, team_id: team.id, event_id: @event.id )
      reader.read()
    elsif name =~ /\/squads\/([a-z]{2,3})-[^\/]+$/
      ## fix: add to country matcher new format
      ##   name is country! and parent folder is type name e.g. /squads/br-brazil

      # note: if two letters, assume country key
      #       if three letters, assume team key

      ##   allow three letter codes
      ##  assume three letter code are *team* codes (e.g. fdr, gdr, etc)
      ##      not country code (allows multiple teams per country)

      if $1.length == 2
        ## get national team via country
        country = Country.find_by_key!( $1 )
        ###  for now assume country code matches team for now (do NOT forget to downcase e.g. BRA==bra)
        logger.info "  assume country code == team code for #{country.code}"
        team = Team.find_by_key!( country.code.downcase )
      else  # assume length == 3
        ## get national team directly (use three letter fifa code)
        team = Team.find_by_key!( $1 )
      end
      ## note: pass in @event.id - that is, last seen event (e.g. parsed via GameReader/MatchReader)
      reader = create_national_team_squad_reader( name, team_id: team.id, event_id: @event.id  )
      reader.read()
    elsif name =~ /(?:^|\/)seasons/  # NB: ^seasons or also possible at-austria!/seasons
      reader = create_season_reader( name )
      reader.read()
    elsif name =~ /(?:^|\/)assocs/  # NB: ^assocs or also possible national-teams!/assocs
      reader = create_assoc_reader( name )
      reader.read()
    elsif match_stadiums_for_country( name ) do |country_key|
            country = Country.find_by_key!( country_key )
            reader = create_ground_reader( name, country_id: country.id )
            reader.read()
          end
    elsif match_leagues_for_country( name ) do |country_key|  # name =~ /^([a-z]{2})\/leagues/
            # auto-add country code (from folder structure) for country-specific leagues
            #  e.g. at/leagues
            country = Country.find_by_key!( country_key )
            reader = create_league_reader( name, club: true, country_id: country.id )
            reader.read()
          end
    elsif name =~ /(?:^|\/)leagues/   # NB: ^leagues or also possible world!/leagues  - NB: make sure goes after leagues_for_country!!
      reader = create_league_reader( name )
      reader.read()
    elsif match_teams_for_country( name ) do |country_key|   # name =~ /^([a-z]{2})\/teams/
            # auto-add country code (from folder structure) for country-specific teams
            #  e.g. at/teams at/teams.2 de/teams etc.
            country = Country.find_by_key!( country_key )
            reader = create_team_reader( name, country_id: country.id )
            reader.read()
          end
    elsif match_clubs_for_country( name ) do |country_key|   # name =~ /^([a-z]{2})\/clubs/
            # auto-add country code (from folder structure) for country-specific clubs
            #  e.g. at/teams at/teams.2 de/teams etc.
            country = Country.find_by_key!( country_key )
            reader = create_team_reader( name, club: true, country_id: country.id )   ## note: always sets club flag to true
            reader.read()
          end
    elsif name =~ /(?:^|\/)teams/   ## fix: check if teams rule above (e.g. /^teams/ )conflicts/matches first ???
      ### fix: use new NationalTeamReader ??? why? why not?
      reader = create_team_reader( name )   ## note: always sets club flag to true / national to true
      reader.read()
    elsif name =~ /(?:^|\/)clubs/
      ### fix: use new ClubReader ??? why? why not?
      reader = create_team_reader( name, club: true )     ## note: always sets club flag to true / national to false
      reader.read()
    elsif name =~ /\.conf$/     ## e.g.  1-premierleague.conf  => 1-premierleague.conf.txt
      reader = create_event_table_reader( name )
      reader.read()
      # note: keep a "public" reference of last event in @event  - e.g. used/required by squads etc.
      @event = reader.event
    elsif name =~ /\/(\d{4}|\d{4}[_\-]\d{2})(--[^\/]+)?\// ||
          name =~ /\/(\d{4}|\d{4}[_\-]\d{2})$/

      # note: allow 2013_14 or 2013-14 (that, is dash or underscore)

      # e.g. must match /2012/ or /2012_13/  or   /2012--xxx/ or /2012_13--xx/
      #  or   /2012 or /2012_13   e.g. brazil/2012 or brazil/2012_13
      reader = create_game_reader( name )
      reader.read()
      # note: keep a "public" reference of last event in @event  - e.g. used/required by squads etc.
      @event = reader.event
    else
      logger.error "unknown sportdb fixture type >#{name}<"
      # todo/fix: exit w/ error
    end
  end # method load

end # class Reader
end # module SportDb


module ODFReport

  module Images

    IMAGE_DIR_NAME = "Pictures"

    def find_image_name_matches(content)

      @images.each_pair do |image_name, path|
        if node = content.xpath("//draw:frame[@draw:name='#{image_name}']/draw:image").first
          placeholder_path = node.attribute('href').value
          @image_names_replacements[path] = ::File.join(IMAGE_DIR_NAME, ::File.basename(placeholder_path))
        end
      end

    end

    def include_image_files(file)

      return if @images.empty?

      @image_names_replacements.each_pair do |path, template_image|

        file.output_stream.put_next_entry(template_image)
        file.output_stream.write ::File.read(path)

      end

    end # replace_images

    # newer versions of LibreOffice can't open files with duplicates image names
    def avoid_duplicate_image_names(content)

      nodes = content.xpath("//draw:frame[@draw:name]")

      nodes.each_with_index do |node, i|
        node.attribute('name').value = "pic_#{i}"
      end

    end

  end

end


module DynamicScaffold
  module ControllerUtilities
    private

      # Get the hash of the key and value specified for the scope.
      def scope_params
        return {} if dynamic_scaffold.scope.nil?

        case dynamic_scaffold.scope
        when Array then
          dynamic_scaffold.scope.each_with_object({}) do |val, res|
            if val.is_a? Hash
              val.each {|k, v| res[k] = v }
            else
              res[val] = params[val]
            end
          end
        when Hash then
          dynamic_scaffold.scope
        end
      end

      # Convert pkey_string value to hash.
      def pkey_string_to_hash(pkey)
        # https://github.com/gomo/dynamic_scaffold/pull/9/commits/ff5de0e38b3544347e82539c45ffd2efaf3410da
        # Stop support multiple pkey, on this commit.
        # Convert "key:1,code:foo" to {key: "1", code: "foo"}
        pkey.split(',').map {|v| v.split(':') }.each_with_object({}) {|v, res| res[v.first] = v.last }
      end

      # Get parameters for edit action. `key[column] => value`
      def edit_params
        params.permit(*dynamic_scaffold.model.primary_key)
      end

      # Get paramters for sort action. `pkeys[][column] => value`
      def sort_params
        params
          .require('pkeys')
          .map {|p| p.permit(*dynamic_scaffold.model.primary_key) }
      end

      # Get paramters for update record.
      def update_values # rubocop:disable Metrics/AbcSize
        # set the parameters of carrierwave_image at the end for validates.
        permitting = []
        dynamic_scaffold.form.items.reject {|i| i.type?(:carrierwave_image) }.each do |item|
          item.extract_parameters(permitting)
        end
        permitting.concat(dynamic_scaffold.form.permit_params)
        dynamic_scaffold.form.items.select {|i| i.type?(:carrierwave_image) }.each do |item|
          item.extract_parameters(permitting)
        end

        values = params
                   .require(dynamic_scaffold.model.name.underscore)
                   .permit(*permitting)

        if dynamic_scaffold.scope && !valid_for_scope?(values)
          raise DynamicScaffold::Error::InvalidOperation, "You can update only to #{scope_params} on this scope"
        end

        values
      end

      # Check if there are inconsistent scopes in update parameters
      def valid_for_scope?(update_params)
        return true if dynamic_scaffold.scope_options[:changeable]

        result = true
        scope_params.each do |key, value|
          if update_params.key?(key) && update_params[key] != value
            result = false
            break
          end
        end
        result
      end

      def reset_sequence(record_count)
        if dynamic_scaffold.list.sorter_direction == :asc
          @sequence = 0
        elsif dynamic_scaffold.list.sorter_direction == :desc
          @sequence = record_count - 1
        end
      end

      def next_sequence!
        val = @sequence
        if dynamic_scaffold.list.sorter_direction == :asc
          @sequence += 1
        elsif dynamic_scaffold.list.sorter_direction == :desc
          @sequence -= 1
        end
        val
      end

      def find_record(params)
        rec = dynamic_scaffold.model.find_by(params.merge(scope_params))
        raise ::ActiveRecord::RecordNotFound if rec.nil?

        rec
      end

      def dynamic_scaffold_path(action, options = {})
        route = Rails.application.routes.routes.find do |r|
          route_params = r.required_defaults
          route_params[:controller] == params[:controller] && (route_params[:action] == action.to_s && r.name)
        end

        if route.nil?
          raise DynamicScaffold::Error::RouteNotFound,
            "Missing controller#action #{params[:controller]}##{action}"
        end

        options.merge!(scope_params)

        public_send("#{route.name}_path", options)
      end

      def bind_sorter_value(record)
        attr = dynamic_scaffold.list.sorter_attribute
        value = dynamic_scaffold.model.maximum(attr)
        record[attr] = value ? value + 1 : 0
      end

      def request_queries(*except)
        request.query_parameters.to_hash.symbolize_keys.delete_if {|k, _v| except.select(&:present?).include?(k) }
      end

      def check_max_count!
        return if dynamic_scaffold.max_count.nil?

        instance_exec(@record, &dynamic_scaffold.lock_before_count) if dynamic_scaffold.lock_before_count
        count_query = dynamic_scaffold.list.build_sql(scope_params)
        count_query = count_query.lock if dynamic_scaffold.max_count_options[:lock]
        count = count_query.count
        raise Error::InvalidOperation, 'You can not add any more.' if dynamic_scaffold.max_count?(count)
      end

      def handle_pagination(query)
        return query unless dynamic_scaffold.list.pagination

        query
          .page(params[dynamic_scaffold.list.pagination.param_name])
          .per(dynamic_scaffold.list.pagination.per_page)
      end
  end
end


module DynamicScaffold
  module Controller
    extend ActiveSupport::Concern
    include ControllerUtilities

    included do
      helper_method :dynamic_scaffold_path, :dynamic_scaffold, :request_queries
      helper DynamicScaffoldHelper
      attr_reader :dynamic_scaffold
      before_action lambda {
        @dynamic_scaffold = Config.new(self.class.dynamic_scaffold_model, self)
        self.class.dynamic_scaffold_initializer.call(@dynamic_scaffold)
      }
    end

    class_methods do
      attr_accessor :dynamic_scaffold_model, :dynamic_scaffold_initializer

      def dynamic_scaffold(model, &block)
        self.dynamic_scaffold_model = model
        self.dynamic_scaffold_initializer = block
      end
    end

    # Actions

    def index # rubocop:disable Metrics/AbcSize
      @records = dynamic_scaffold.list.build_sql(scope_params)
      @count = @records.count unless dynamic_scaffold.max_count.nil?
      @records = handle_pagination(@records)
      @records = @records.order dynamic_scaffold.list.sorter if dynamic_scaffold.list.sorter
      @records = @records.order(*dynamic_scaffold.list.order) unless dynamic_scaffold.list.order.empty?

      ret = yield(@records) if block_given?
      @records = ret unless ret.nil?
      unless @records.is_a? ::ActiveRecord::Relation
        raise(
          Error::InvalidOperation,
          'You must return ActiveRecord::Relation from super block'
        )
      end

      @records
    end

    def new # rubocop:disable Metrics/AbcSize
      unless dynamic_scaffold.max_count.nil?
        count = dynamic_scaffold.list.build_sql(scope_params).count
        raise Error::InvalidOperation, 'You can not add any more.' if dynamic_scaffold.max_count?(count)
      end

      @record = dynamic_scaffold.model.new

      defaults = dynamic_scaffold.form.items.each_with_object({}) do |item, memo|
        memo[item.name] = item.default_value(view_context) if dynamic_scaffold.model.column_names.include?(item.name.to_s)
      end

      @record.attributes = defaults.merge(scope_params)
    end

    def edit
      @record = find_record(edit_params)
    end

    def create
      @record = dynamic_scaffold.model.new
      @record.attributes = update_values
      bind_sorter_value(@record) if dynamic_scaffold.list.sorter
      dynamic_scaffold.model.transaction do
        check_max_count!
        yield(@record) if block_given?
        if @record.save
          redirect_to dynamic_scaffold_path(:index, request_queries)
        else
          render "#{params[:controller]}/new"
        end
      end
    end

    def update
      values = update_values
      @record = find_record(dynamic_scaffold.model.primary_key => params['id'])
      prev_attribute = @record.attributes
      @record.attributes = values
      dynamic_scaffold.model.transaction do
        yield(@record, prev_attribute) if block_given?
        if @record.save
          redirect_to dynamic_scaffold_path(:index, request_queries)
        else
          render "#{params[:controller]}/edit"
        end
      end
    end

    def destroy
      # `Destroy` also does not support multiple primary keys too.
      record = find_record(dynamic_scaffold.model.primary_key => params['id'])
      begin
        dynamic_scaffold.model.transaction do
          record.destroy
        end
      rescue ::ActiveRecord::InvalidForeignKey => _error
        flash[:dynamic_scaffold_danger] = I18n.t('dynamic_scaffold.alert.destroy.invalid_foreign_key')
      rescue StandardError => error
        flash[:dynamic_scaffold_danger] = I18n.t('dynamic_scaffold.alert.destroy.failed')
        logger.error(error)
      end
      redirect_to dynamic_scaffold_path(:index, request_queries)
    end

    def sort
      pkeys_list = sort_params
      reset_sequence(pkeys_list.size)
      dynamic_scaffold.model.transaction do
        pkeys_list.each do |pkeys|
          rec = find_record(pkeys.to_hash)
          next_sec = next_sequence!
          rec[dynamic_scaffold.list.sorter_attribute] = next_sec
          rec.save
        end
      end
      redirect_to dynamic_scaffold_path(:index, request_queries)
    end
  end
end


require 'digest'

module RedisLocks
  class RateLimitExceeded < ResourceUnavailable
    def initialize(key, rps)
      super("Rate limit of #{rps}/second on [#{key}] exceeded!")
    end
  end

  class TokenBucket

    NAMESPACE = "token-bucket"

    SCRIPT = <<-LUA
      local epoch = tonumber(ARGV[1])
      local rps = tonumber(ARGV[2])
      local burst = tonumber(ARGV[3])
      local key = KEYS[1]

      local token = 1.0 / rps
      local t = redis.call('get', key)
      if not t then
        t = epoch
      else
        t = tonumber(t)
      end

      if t < epoch then
        t = epoch
      elseif t > (epoch + (burst * token)) then
        return 0
      end

      redis.call('set', key, t + token)
      return 1
    LUA

    DIGEST = Digest::SHA1.hexdigest(SCRIPT)

    # `number` tokens are added to the bucket every `period` seconds (up to a
    # max of `number` tokens being available). Each time a resource is used, a
    # token is removed from the bucket; if no tokens are available, no resource
    # may be used.
    def initialize(key, period: 1, number: 1, redis: RedisLocks.redis)
      @key = "#{NAMESPACE}:#{key}".freeze
      @rps = number.to_f / period.to_i
      @burst = number.to_i
      @redis = Connections.ensure_pool(redis)
    end

    def take
      took = @redis.with do |conn|
        RedisLocks.evalsha_or_eval(
          conn: conn,
          script: SCRIPT,
          digest: DIGEST,
          keys: [@key],
          args: [epoch_f(conn), @rps, @burst]
        )
      end

      took == 1
    end

    def take!
      raise RateLimitExceeded.new(@key, @rps) unless take
    end

    private

    def epoch_f(conn)
      epoch_i, microseconds = conn.time
      epoch_i + (microseconds.to_f/1_000_000)
    end

  end
end


# Adapted from https://github.com/dv/redis-semaphore - switched to use Lua
# instead of broken mutex implementation, see issue #23 on GitHub.
#
# Options we don't use were removed for simplicity.
#
# Original copyright (C) 2011 by David Verhasselt (david@crowdway.com)
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
# THE SOFTWARE.
#
require 'digest'

module RedisLocks
  class SemaphoreUnavailable < ResourceUnavailable
    def initialize(key, resources)
      super("Key [#{key}] has 0/#{resources} resources available!")
    end
  end

  class Semaphore

    NAMESPACE = "semaphore-lua"

    # Removes stale locks, then ensures that all resources which aren't locked
    # are marked as available.
    SETUP_SCRIPT = <<-LUA
      local avail_key = KEYS[1]
      local grabbed_key = KEYS[2]

      local expected_resources = tonumber(ARGV[1])
      local stale_before = tonumber(ARGV[2])

      redis.call('zremrangebyscore', grabbed_key, -1, stale_before)

      local found_resources = redis.call('llen', avail_key) + redis.call('zcard', grabbed_key)
      if found_resources < expected_resources then
        for i=1,(expected_resources - found_resources) do
          redis.call('rpush', avail_key, 1)
        end
      end
    LUA

    SETUP_DIGEST = Digest::SHA1.hexdigest(SETUP_SCRIPT)

    # `resources` is the number of clients allowed to lock the semaphore
    # concurrently.
    #
    # `stale_client_timeout` is the threshold of time before we assume that
    # something has gone terribly wrong with a client and we invalidate its lock.
    def initialize(key, resources: 1, stale_client_timeout: 86400, redis: RedisLocks.redis)
      @key = key
      @resource_count = resources.to_i
      @stale_client_timeout = stale_client_timeout.to_f
      @redis = Connections.ensure_pool(redis)
      @tokens = []

      raise ArgumentError.new("Lock key is required") if @key.nil? || @key.empty?
      raise ArgumentError.new("resources must be > 0") unless @resource_count > 0
      raise ArgumentError.new("stale_client_timeout must be > 0") unless @stale_client_timeout > 0
    end

    # Forcefully clear the lock. Be careful!
    def delete!
      @redis.with do |conn|
        conn.del(available_key)
        conn.del(grabbed_key)
      end

      @tokens = []
    end

    # Acquire a resource from the semaphore, if available. Returns false if no
    # resources are available.
    #
    # `timeout` is how long to wait, blocking, until a resource is available.
    # The default is nil, meaning don't block. A timeout of zero means block forever.
    # (This is a bit weird, but corresponds to how blpop uses timeout values.)
    #
    # If passed a block, if a resource is available, runs the block and then
    # unlocks.
    #
    # If called without a block, if a resource is available, returns a token.
    # Caller is then responsible for unlocking the token.
    #
    # This isn't atomic--if the process dies, we could remove something from the
    # available queue without adding it to the grabbed set--but that's ok, the
    # semaphore will recover just as if this was a stale client that left its
    # token in the grabbed set forever.
    def lock(timeout: nil, &block)
      ensure_exists_and_release_stale_locks!

      success = @redis.with do |conn|
        if timeout
          !conn.blpop(available_key, timeout.to_i).nil?
        else
          !conn.lpop(available_key).nil?
        end
      end

      return false unless success

      token = SecureRandom.hex(16)
      @tokens.push(token)
      @redis.with do |conn|
        conn.zadd(grabbed_key, epoch_f(conn), token)
      end

      return_or_yield(token, &block)
    end

    def wait(timeout: 0, &block)
      lock(timeout: timeout, &block)
    end

    def lock!(timeout: nil, &block)
      token = lock(timeout: timeout)
      raise SemaphoreUnavailable.new(@key, @resource_count) unless token
      return_or_yield(token, &block)
    end

    def wait!(timeout: 0, &block)
      lock!(timeout: timeout, &block)
    end

    # Release a resource back to the semaphore. Should normally be called with an
    # explicit token.
    #
    # This isn't atomic--if the process dies, we could remove something from the
    # blocked set without adding it to the available queue--but that's ok, the
    # semaphore will recover just as if this was a stale client that left its
    # token in the grabbed set forever.
    def unlock(token = @tokens.pop)
      return unless token

      removed = false

      @redis.with do |conn|
        removed = conn.zrem grabbed_key, token
        if removed
          conn.lpush available_key, 1
        end
      end

      removed
    end
    alias_method :signal, :unlock

    private

    def return_or_yield(token)
      return_value = token
      if block_given?
        begin
          return_value = yield token
        ensure
          unlock(token)
        end
      end
      return_value
    end

    def ensure_exists_and_release_stale_locks!
      @redis.with do |conn|
        RedisLocks.evalsha_or_eval(
          conn: conn,
          script: SETUP_SCRIPT,
          digest: SETUP_DIGEST,
          keys: [available_key, grabbed_key],
          args: [@resource_count, stale_before(conn)]
        )
      end
    end

    def namespaced_key(variable)
      "#{NAMESPACE}:#{@key}:#{variable}"
    end

    def available_key
      @available_key ||= namespaced_key('AVAILABLE')
    end

    def grabbed_key
      @grabbed_key ||= namespaced_key('GRABBED')
    end

    def stale_before(conn)
      epoch_f(conn) - @stale_client_timeout
    end

    def epoch_f(conn)
      epoch_i, microseconds = conn.time
      epoch_i + microseconds.to_f / 1_000_000
    end

  end
end


require 'libnotify/ffi'
require 'libnotify/icon_finder'

module Libnotify
  # API for Libnotify
  #
  # @see Libnotify
  class API
    include FFI

    attr_reader :timeout, :icon_path
    attr_accessor :app_name, :summary, :body, :urgency, :append, :transient

    class << self
      # List of globs to icons
      attr_accessor :icon_dirs
    end

    self.icon_dirs = [
      "/usr/share/icons/gnome/*/emblems",
      "/usr/share/icons/gnome/*/emotes"
    ]

    # Creates a notification object.
    #
    # @see Libnotify.new
    def initialize(options={}, &block)
      set_defaults
      apply_options(options, &block)
    end

    def apply_options(options={})
      options.each { |key, value| send("#{key}=", value) if respond_to?(key) }
      yield(self) if block_given?
    end
    private :apply_options

    def set_defaults
      self.app_name = self.class.to_s
      self.summary = self.body = ' '
      self.urgency = :normal
      self.timeout = nil
      self.append = true
      self.transient = false
    end
    private :set_defaults

    # Shows a new notification.
    #
    # @see Libnotify.show
    def show!
      notify_init(app_name) or raise "notify_init failed"
      raw_ptr = notify_notification_new(summary, body, icon_path, nil)
      @notification = ::FFI::AutoPointer.new(raw_ptr, method(:g_object_unref))
      show
    end

    # Shows an existing notification.
    def show
      notify_notification_set_urgency(@notification, urgency)
      notify_notification_set_timeout(@notification, timeout || -1)
      set_hints
      notify_notification_show(@notification, nil)
    ensure
      clear_hints
    end

    # Updates a previously shown notification or creates a new one.
    def update(options={}, &block)
      apply_options(options, &block)
      if @notification
        notify_notification_update(@notification, summary, body, icon_path, nil)
        show
      else
        show!
      end
    end

    # Close a previously shown notification.
    def close
      notify_notification_close(@notification, nil) if @notification
    end

    # @todo Simplify timeout=
    def timeout=(timeout)
      @timeout = case timeout
      when Float
        (timeout * 1000).to_i
      when Integer
        if timeout >= 100 # assume miliseconds
          timeout
        else
          timeout * 1000
        end
      when NilClass, FalseClass
        nil
      else
        timeout.to_s.to_i
      end
    end

    # Sets icon path.
    #
    # Path can be absolute, relative (will be resolved) or a symbol.
    #
    # @todo document and refactor
    def icon_path=(path)
      case path
      when %r{^/} # absolute
        @icon_path = path
      when String
        @icon_path = icon_for(path)
      when Symbol
        self.icon_path = "#{path}.png"
      else
        @icon_path = nil
      end
    end

    # Creates and shows a notification. It's a shortcut for +Libnotify.new(options).show!+.
    #
    # @see Libnotify.show
    # @see Libnotify.new
    def self.show(options={}, &block)
      new(options, &block).show!
    end

    private

    def set_hints
      if append
        notify_notification_set_hint_string(@notification, "x-canonical-append", "")
        notify_notification_set_hint_string(@notification, "append", "")
      end
      if transient
        notify_notification_set_hint_uint32(@notification, "transient", 1)
      end
    end

    def clear_hints
      notify_notification_clear_hints(@notification) if (append || transient)
    end

    def icon_for(name)
      IconFinder.new(self.class.icon_dirs).icon_path(name) || name
    end
  end
end


module YoutubeDL
  # Some support methods and glue logic.
  module Support
    # Returns a usable youtube-dl executable (system or vendor)
    #
    # @param exe [String] Executable to search for
    # @return [String] executable path
    def usable_executable_path_for(exe)
      system_path = which(exe)
      if system_path.nil?
        # TODO: Search vendor bin for executable before just saying it's there.
        vendor_path = File.absolute_path("#{__FILE__}/../../../vendor/bin/#{exe}")
        File.chmod(775, vendor_path) unless File.executable?(vendor_path) # Make sure vendor binary is executable
        vendor_path
      else
        system_path.strip
      end
    end

    alias_method :executable_path_for, :usable_executable_path_for

    # Helper for doing lines of cocaine (initializing, auto executable stuff, etc)
    #
    # @param command [String] command switches to run
    # @param executable_path [String] executable to run. Defaults to usable youtube-dl.
    # @return [Cocaine::CommandLine] initialized Cocaine instance
    def cocaine_line(command, executable_path = nil)
      executable_path = executable_path_for('youtube-dl') if executable_path.nil?
      Cocaine::CommandLine.new(executable_path, command)
    end

    # Helper to add quotes to beginning and end of a URL or whatever you want....
    #
    # @param url [String] Raw URL
    # @return [String] Quoted URL
    def quoted(url)
      "\"#{url}\""
    end

    # Cross-platform way of finding an executable in the $PATH.
    # Stolen from http://stackoverflow.com/a/5471032
    #
    #   which('ruby') #=> /usr/bin/ruby
    #
    # @param cmd [String] cmd to search for
    # @return [String] full path for the cmd
    def which(cmd)
      exts = ENV['PATHEXT'] ? ENV['PATHEXT'].split(';') : ['']
      ENV['PATH'].split(File::PATH_SEPARATOR).each do |path|
        exts.each do |ext|
          exe = File.join(path, "#{cmd}#{ext}")
          return exe if File.executable?(exe) && !File.directory?(exe)
        end
      end
      nil
    end
  end
end


module YoutubeDL
  # Video model for using and downloading a single video.
  class Video < Runner
    class << self
      # Instantiate a new Video model and download the video
      #
      #   YoutubeDL.download 'https://www.youtube.com/watch?v=KLRDLIIl8bA' # => #<YoutubeDL::Video:0x00000000000000>
      #   YoutubeDL.get 'https://www.youtube.com/watch?v=ia1diPnNBgU', extract_audio: true, audio_quality: 0
      #
      # @param url [String] URL to use and download
      # @param options [Hash] Options to pass in
      # @return [YoutubeDL::Video] new Video model
      def download(url, options = {})
        video = new(url, options)
        video.download
        video
      end
      alias_method :get, :download
    end

    # @return [YoutubeDL::Options] Download Options for the last download
    attr_reader :download_options

    # Instantiate new model
    #
    # @param url [String] URL to initialize with
    # @param options [Hash] Options to populate the everything with
    def initialize(url, options = {})
      @url = url
      @options = YoutubeDL::Options.new(options.merge(default_options))
      @options.banned_keys = banned_keys
    end

    # Download the video.
    def download
      raise ArgumentError.new('url cannot be nil') if @url.nil?
      raise ArgumentError.new('url cannot be empty') if @url.empty?

      set_information_from_json(YoutubeDL::Runner.new(url, runner_options).run)
    end

    alias_method :get, :download

    # Returns the expected filename
    #
    # @return [String] Filename downloaded to
    def filename
      self._filename
    end

    # Metadata information for the video, gotten from --print-json
    #
    # @return [OpenStruct] information
    def information
      @information || grab_information_without_download
    end

    # Redirect methods for information getting
    #
    # @param method [Symbol] method name
    # @param args [Array] method arguments
    # @param block [Proc] explict block
    # @return [Object] The value from @information
    def method_missing(method, *args, &block)
      value = information[method]

      if value.nil?
        super
      else
        value
      end
    end

  private

    # Add in other default options here.
    def default_options
      {
        color: false,
        progress: false,
        print_json: true
      }
    end

    def banned_keys
      [
        :get_url,
        :get_title,
        :get_id,
        :get_thumbnail,
        :get_description,
        :get_duration,
        :get_filename,
        :get_format
      ]
    end

    def runner_options
      YoutubeDL::Options.new(@options.to_h.merge(default_options))
    end

    def set_information_from_json(json) # :nodoc:
      @information = JSON.parse(json, symbolize_names: true)
    end

    def grab_information_without_download # :nodoc:
      set_information_from_json(YoutubeDL::Runner.new(url, runner_options.with({skip_download: true})).run)
    end
  end
end


module YoutubeDL
  # Utility class for running and managing youtube-dl
  class Runner
    include YoutubeDL::Support

    # @return [String] URL to download
    attr_accessor :url

    # @return [YoutubeDL::Options] Options access.
    attr_accessor :options

    # @return [String] Executable path
    attr_reader :executable_path

    # @return [String] Executable name to use
    attr_accessor :executable

    # Command Line runner initializer
    #
    # @param url [String] URL to pass to youtube-dl executable
    # @param options [Hash, Options] options to pass to the executable. Automatically converted to Options if it isn't already
    def initialize(url, options = {})
      @url = url
      @options = YoutubeDL::Options.new(options)
      @executable = 'youtube-dl'
    end

    # Returns usable executable path for youtube-dl
    #
    # @return [String] usable executable path for youtube-dl
    def executable_path
      @executable_path ||= usable_executable_path_for(@executable)
    end

    # Returns Cocaine's runner engine
    #
    # @return [CommandLineRunner] backend runner class
    def backend_runner
      Cocaine::CommandLine.runner
    end

    # Sets Cocaine's runner engine
    #
    # @param cocaine_runner [CommandLineRunner] backend runner class
    # @return [Object] whatever Cocaine::CommandLine.runner= returns.
    def backend_runner=(cocaine_runner)
      Cocaine::CommandLine.runner = cocaine_runner
    end

    # Returns the command string without running anything
    #
    # @return [String] command line string
    def to_command
      cocaine_line(options_to_commands).command(@options.store)
    end
    alias_method :command, :to_command

    # Runs the command
    #
    # @return [String] the output of youtube-dl
    def run
      cocaine_line(options_to_commands).run(@options.store)
    end
    alias_method :download, :run

    # Options configuration.
    # Just aliases to options.configure
    #
    # @yield [config] options
    # @param a [Array] arguments to pass to options#configure
    # @param b [Proc] block to pass to options#configure
    def configure(*a, &b)
      options.configure(*a, &b)
    end

    private

    # Parses options and converts them to Cocaine's syntax
    #
    # @return [String] commands ready to do cocaine
    def options_to_commands
      commands = []
      @options.sanitize_keys.each_paramized_key do |key, paramized_key|
        if @options[key].to_s == 'true'
          commands.push "--#{paramized_key}"
        elsif @options[key].to_s == 'false'
          commands.push "--no-#{paramized_key}"
        else
          commands.push "--#{paramized_key} :#{key}"
        end
      end
      commands.push quoted(url)
      commands.join(' ')
    end
  end
end


module YoutubeDL
  # Option and configuration getting, setting, and storage, and all that
  class Options
    # @return [Hash] key value storage object
    attr_accessor :store

    # @return [Array] array of keys that won't be saved to the options store
    attr_accessor :banned_keys

    # Options initializer
    #
    # @param options [Hash] a hash of options
    def initialize(options = {})
      if options.is_a? Hash
        @store = options
      else
        @store = options.to_h
      end

      @banned_keys = []
    end

    # Returns options as a hash
    #
    # @return [Hash] hash of options
    def to_hash
      remove_banned
      @store
    end
    alias_method :to_h, :to_hash

    # Iterate through the paramized keys and values.
    #
    # @yield [paramized_key, value]
    # @return [Object] @store
    #
    # TODO: Enumerable?
    def each_paramized
      @store.each do |key, value|
        yield(paramize(key), value)
      end
    end

    # Iterate through the keys and their paramized counterparts.
    #
    # @yield [key, paramized_key]
    # @return [Object] @store
    def each_paramized_key
      @store.each_key do |key|
        yield(key, paramize(key))
      end
    end

    # Set options using a block
    #
    # @yield [config] self
    def configure
      yield(self) if block_given?
      remove_banned
      self
    end

    # Get option with brackets syntax
    #
    # @param key [Object] key
    # @return [Object] value
    def [](key)
      remove_banned
      return nil if banned? key
      @store[key.to_sym]
    end

    # Set option with brackets syntax
    #
    # @param key [Object] key
    # @param value [Object] value
    # @return [Object] whatever Hash#= returns
    def []=(key, value)
      remove_banned
      return nil if banned? key
      @store[key.to_sym] = value
    end

    # Merge options with given hash, removing banned keys, and returning a
    # new instance of Options.
    #
    # @param hash [Hash] Hash to merge options with
    # @return [YoutubeDL::Options] Merged Options instance
    def with(hash)
      merged = Options.new(@store.merge(hash.to_h))
      merged.banned_keys = @banned_keys
      merged.send(:remove_banned)
      merged
    end

    # Option getting and setting using ghost methods
    #
    # @param method [Symbol] method name
    # @param args [Array] list of arguments passed
    # @param block [Proc] implicit block given
    # @return [Object] the value of method in the options store
    def method_missing(method, *args, &_block)
      remove_banned
      if method.to_s.include? '='
        method = method.to_s.tr('=', '').to_sym
        return nil if banned? method
        @store[method] = args.first
      else
        return nil if banned? method
        @store[method]
      end
    end

    # Calls a block to do operations on keys
    # See sanitize_keys! for examples
    #
    # @param block [Proc] Block with operations on keys
    # @yieldparam key [Object] Original key
    # @yieldreturn [Object] Manipulated key
    def manipulate_keys!(&block)
      @store.keys.each do |old_name|
        new_name = block.call(old_name)
        unless new_name == old_name
          @store[new_name] = @store[old_name]
          @store.delete(old_name)
        end
      end
    end

    # Symbolizes and sanitizes keys in the option store
    #
    # @return [Object] @store
    def sanitize_keys!
      # Symbolize
      manipulate_keys! { |key_name| key_name.is_a?(Symbol) ? key_name : key_name.to_sym }

      # Underscoreize (because Cocaine doesn't like hyphens)
      manipulate_keys! { |key_name| key_name.to_s.tr('-', '_').to_sym }
    end

    # Symbolizes and sanitizes keys and returns a copy of self
    #
    # @return [YoutubeDL::Options] Options with sanitized keys.
    def sanitize_keys
      safe_copy = dup
      safe_copy.sanitize_keys!
      safe_copy
    end

    # Check if key is a banned key
    #
    # @param key [Object] key to check
    # @return [Boolean] true if key is banned, false if not.
    def banned?(key)
      @banned_keys.include? key
    end

  private

    # Helper function to convert option keys into command-line-friendly parameters
    #
    # @param key [Symbol, String] key to paramize
    # @return [String] paramized key
    def paramize(key)
      key.to_s.tr('_', '-')
    end

    # Helper to remove banned keys from store
    def remove_banned
      @store.delete_if { |key, value| banned? key }
    end
  end
end


require 'guard/compat/plugin'
require 'timeout'

module Guard
  class Resque < Plugin

    DEFAULT_SIGNAL = :QUIT
    DEFAULT_QUEUE = '*'.freeze
    DEFAULT_COUNT = 1
    DEFAULT_TASK_SINGLE = 'resque:work'.freeze
    DEFAULT_TASK_MULTIPLE = 'resque:workers'.freeze

    # Allowable options are:
    #  - :environment  e.g. 'test'
    #  - :task .e.g 'resque:work'
    #  - :queue e.g. '*'
    #  - :count e.g. 3
    #  - :interval e.g. 5
    #  - :verbose e.g. true
    #  - :vverbose e.g. true
    #  - :trace e.g. true
    #  - :stop_signal e.g. :QUIT or :SIGQUIT
    def initialize(options = {})
      super
      @options = options
      @pid = nil
      @stop_signal = options[:stop_signal] || DEFAULT_SIGNAL
      @options[:queue] ||= DEFAULT_QUEUE
      @options[:count] ||= DEFAULT_COUNT
      @options[:task] ||= (@options[:count].to_i == 1) ? DEFAULT_TASK_SINGLE : DEFAULT_TASK_MULTIPLE
    end

    def start
      stop
      UI.info 'Starting up resque...'
      UI.info [ cmd, env.map{|v| v.join('=')} ].join(' ')

      # launch Resque worker
      @pid = spawn(env, cmd)
    end

    def stop
      if @pid
        UI.info 'Stopping resque...'
        ::Process.kill @stop_signal, @pid
        begin
          Timeout.timeout(15) do
            ::Process.wait @pid
          end
        rescue Timeout::Error
          UI.info 'Sending SIGKILL to resque, as it\'s taking too long to shutdown.'
          ::Process.kill :KILL, @pid
          ::Process.wait @pid
        end
        UI.info 'Stopped process resque'
      end
    rescue Errno::ESRCH
      UI.info 'Guard::Resque lost the Resque worker subprocess!'
    ensure
      @pid = nil
    end

    # Called on Ctrl-Z signal
    def reload
      UI.info 'Restarting resque...'
      restart
    end

    # Called on Ctrl-/ signal
    def run_all
      true
    end

    # Called on file(s) modifications
    def run_on_changes(paths)
      restart
    end

    def restart
      stop
      start
    end

    private

    def cmd
      command = ['bundle exec rake', @options[:task].to_s]

      # trace setting
      command << '--trace' if @options[:trace]

      return command.join(' ')
    end

    def env
      var = Hash.new

      var['INTERVAL']  = @options[:interval].to_s    if @options[:interval]
      var['QUEUE']     = @options[:queue].to_s       if @options[:queue]
      var['COUNT']     = @options[:count].to_s       if @options[:count]
      var['RAILS_ENV'] = @options[:environment].to_s if @options[:environment]

      var['VERBOSE']  = '1' if @options[:verbose]
      var['VVERBOSE'] = '1' if @options[:vverbose]

      return var
    end
  end
end


require 'unicorn'

module Ernicorn
  Stats = Raindrops::Struct.new(:connections_total,
                                :connections_completed,
                                :workers_idle).new

  class Server < Unicorn::HttpServer

    # Private HttpServer methods we're overriding to
    # implement BertRPC instead of HTTP.

    def initialize(app, options={})
      super app, options
    end

    def build_app!
    end

    def worker_loop(worker)
      Stats.incr_workers_idle

      if worker.nr == (self.worker_processes - 1)
        old_pid = "#{self.pid}.oldbin"
        if File.exists?(old_pid) && self.pid != old_pid
          begin
            Process.kill("QUIT", File.read(old_pid).to_i)
          rescue Errno::ENOENT, Errno::ESRCH
            # someone else did our job for us
          end
        end
      end

      super
    ensure
      Stats.decr_workers_idle
    end

    def process_client(client)
      Ernicorn.respond_to?(:around_filter) ?
        Ernicorn.around_filter { process_ernicorn_client(client) } :
        process_ernicorn_client(client)
    end

    def process_ernicorn_client(client)
      Stats.decr_workers_idle
      Stats.incr_connections_total

      @client = client
      # bail out if client only sent EOF
      return if @client.kgio_trypeek(1).nil?

      iruby, oruby = Ernicorn.process(self, self)
    rescue EOFError
      logger.error("EOF from #{@client.kgio_addr rescue nil}")
    rescue Object => e
      logger.error(e)

      begin
        error = t[:error, t[:server, 0, e.class.to_s, e.message, e.backtrace]]
        Ernicorn.write_berp(self, error)
      rescue Object => ex
        logger.error(ex)
      end
    ensure
      @client.close rescue nil
      @client = nil

      Stats.incr_connections_completed
      Stats.incr_workers_idle
    end

    # We pass ourselves as both input and output to Ernicorn.process, which
    # calls the following blocking read/write methods.

    def read(len)
      data = ''
      while data.bytesize < len
        data << @client.kgio_read!(len - data.bytesize)
      end
      data
    end

    def write(data)
      @client.kgio_write(data)
    end

    def stop(graceful = true)
      self.pid = "#{self.pid}.#{$$}" if pid =~ /\.oldbin\z/
      super
    end
  end
end