#!/usr/pkg/bin/lua5.1


----------------------
-- Helper functions --
----------------------

function message(...)
  io.stderr:write(...)
end


------------------------------------------
-- Print notice related to tie-breaking --
------------------------------------------

message("NOTICE: This simplified version of the program does not perform tie-breaking.\n\n")


--------------------------------------
-- Command line argument processing --
--------------------------------------

settings = {}

do
  local next_arg
  do
    local argv = {...}
    local i = 0
    next_arg = function()
      i = i + 1
      return argv[i]
    end
  end
  local function command_line_error()
    message("Get help with -h or --help.\n")
    os.exit(1)
  end
  for option in next_arg do
    local argument, lower_argument
    local function require_argument()
      argument = next_arg()
      if argument == nil then
        message('Command line option "', option, '" requires an argument.\n')
        command_line_error()
      end
      lower_argument = string.lower(argument)
    end
    local function set_setting_once(key, value)
      if settings[key] ~= nil then
        message('Command line option "', option, '" occurred multiple times.\n')
        command_line_error()
      end
      settings[key] = value
    end
    if option == "-h" or option == "--help" then
      io.stdout:write("Usage:\n")
      io.stdout:write("schuool   -c|--candidates <candidates_file>\n")
      io.stdout:write("          -b|--ballots    <ballots_file>\n")
      io.stdout:write("        [ -d|--default    n|neutral|f|first|l|last            ]\n")
      io.stdout:write("        [ -o|--output     <output_file>                       ]\n")
      os.exit(0)
    elseif option == "-c" or option == "--candidates" then
      require_argument()
      set_setting_once("candidates_filename", argument)
    elseif option == "-b" or option == "--ballots" then
      require_argument()
      set_setting_once("ballots_filename", argument)
    elseif option == "-d" or option == "--default" then
      require_argument()
      if lower_argument == "n" or lower_argument == "neutral" then
        set_setting_once("default", "n")
      elseif lower_argument == "f" or lower_argument == "first" then
        set_setting_once("default", "f")
      elseif lower_argument == "l" or lower_argument == "last" then
        set_setting_once("default", "l")
      else
        message('Unknown default position "', argument, '" specified after ', option, ' switch.\n')
        command_line_error()
      end
    elseif option == "-o" or option == "--output" then
      require_argument()
      set_setting_once("output_filename", argument)
    else
      message('Illegal command line option "', option, '"\n')
      command_line_error()
    end
  end
  if settings.candidates_filename == nil then
    message("Use -c or --candidates to specify file containing candidate information.\n")
    command_line_error()
  end
  if settings.ballots_filename == nil then
    message("Use -b or --ballots to specify file containing all ballot data.\n")
    command_line_error()
  end
end


--------------------------
-- I/O helper functions --
--------------------------

function strip(str)
  return string.match(str, "^%s*(.-)%s*$")
end

function stripped_lines(filename)
  local file, errmsg = io.open(filename, "r")
  if not file then
    message(errmsg, "\n")
    os.exit(2)
  end
  local get_next_line = file:lines(filename)
  return function()
    if not file then return nil end
    local line
    repeat
      line = get_next_line()
      if line == nil then
        file:close()
        file = nil
        return nil
      end
      line = strip(string.match(line, "^[^#]*"))
    until line ~= ""
    return line
  end
end

function stripped_gmatch(str, pattern)
  local next_entry = string.gmatch(str, pattern)
  return function()
    local entry
    repeat
      entry = next_entry()
      if entry then entry = strip(entry) end
    until entry ~= ""
    return entry
  end
end

do
  local output_file
  if settings.output_filename == nil then
    output_file = io.stdout
  else
    local errmsg
    output_file, errmsg = io.open(settings.output_filename, "w")
    if not output_file then
      message(errmsg, "\n")
      os.exit(2)
    end
  end
  function output(...)
    output_file:write(...)
  end
end

function padded_number(number, maximum)
  local str = tostring(number)
  local max_digits = 1
  local tmp = maximum
  while tmp >= 10 do
    tmp = math.floor(tmp / 10)
    max_digits = max_digits + 1
  end
  for i = 1, max_digits - #str do
    str = " " .. str
  end
  return str
end


---------------------
-- Read candidates --
---------------------

candidates = {}  -- mapping string to candidate number and vice versa

do
  for line in stripped_lines(settings.candidates_filename) do
    for candidate in stripped_gmatch(line, "[^;,]+") do
      if candidates[candidate] then
        message('Duplicate candidate in "', settings.candidates_filename, '": "', candidate, '".\n')
        os.exit(2)
      end
      candidates[#candidates+1] = candidate
      candidates[candidate] = #candidates
    end
  end
end


------------------------------
-- Read and process ballots --
------------------------------

ballots = {}
approval_counts = {}
disapproval_counts = {}
for i = 1, #candidates do
  approval_counts[candidates[i]] = 0
  disapproval_counts[candidates[i]] = 0
end

for line in stripped_lines(settings.ballots_filename) do
  local ballot = {}
  local rank
  local processed = {}
  local approvals, neutrals, disapprovals = string.match(line, "^([^/]*)/([^/]*)/([^/]*)$")
  if not approvals then
    approvals, neutrals, disapprovals = string.match(line, "^([^/]*)$"), "", ""
  end
  if not approvals then
    message('Ill formatted ballot: "', line, '".\n')
    os.exit(2)
  end
  local function process_lists(candidate_lists, count_table)
    for candidate_list in string.gmatch(candidate_lists, "[^;]+") do
      if rank == -1 then
        -- only happens when there are different rankings in the neutral section
        message('Different rankings (semicolon) found in neutral section of ballot "', line, '".\n')
        os.exit(2)
      end
      local empty = true
      for candidate in stripped_gmatch(candidate_list, "[^,]+") do
        empty = false
        if not candidates[candidate] then
          message('Unknown candidate "', candidate, '" contained in ballot "', line, '".\n')
          os.exit(2)
        end
        if processed[candidate] then
          message('Duplicate candidate "', candidate, '" in ballot "', line, '".\n')
          os.exit(2)
        end
        ballot[candidate] = rank
        if count_table then
          count_table[candidate] = count_table[candidate] + 1
        end
        processed[candidate] = true
      end
      if not empty then
        -- It is important to only decrease rank, when candidates have been processed.
        rank = rank - 1
      end
    end
  end
  rank = #candidates
  process_lists(approvals, approval_counts)
  rank = 0
  process_lists(neutrals)
  rank = -2  -- rank -1 is reserved for default=first
  process_lists(disapprovals, disapproval_counts)
  for i = 1, #candidates do
    local candidate = candidates[i]
    if not processed[candidate] then
      if settings.default == "n" then
        ballot[candidate] = 0
      elseif settings.default == "f" then
        ballot[candidate] = -1
        disapproval_counts[candidate] = disapproval_counts[candidate] + 1
      elseif settings.default == "l" then
        ballot[candidate] = -#candidates - 2
        disapproval_counts[candidate] = disapproval_counts[candidate] + 1
      else
        message('Candidate "', candidate, '" missing in ballot "', line, '".\n')
        os.exit(2)
      end
    end
  end
  ballots[#ballots+1] = ballot
end


-------------------------------------------------------------------------
-- Select approved candidates, who passed the hard-coded quota of 1/2+ --
-------------------------------------------------------------------------

local approved_candidates = {}

do
  local max_approval    = 0
  local max_disapproval = 0
  local max_neutral     = 0
  for i = 1, #candidates do
    local candidate = candidates[i]
    local approval_count    = approval_counts[candidate]
    local disapproval_count = disapproval_counts[candidate]
    local neutral_count     = #ballots - approval_count - disapproval_count
    max_approval    = math.max(max_approval,    approval_count)
    max_disapproval = math.max(max_disapproval, disapproval_count)
    max_neutral     = math.max(max_neutral,     neutral_count)
  end
  output("Candidates:\n")
  for i = 1, #candidates do
    local candidate = candidates[i]
    local approval_count    = approval_counts[candidate]
    local disapproval_count = disapproval_counts[candidate]
    local neutral_count     = #ballots - approval_count - disapproval_count
    local approved = approval_count > disapproval_count
    output(padded_number(i, #candidates), ". ", padded_number(approval_count, max_approval), ':', padded_number(disapproval_count, max_disapproval), '(:', padded_number(neutral_count, max_neutral), ') 1/2+ ', approved and "APPROVED" or "FAILED  ", ' ', candidates[i], "\n")
    if approved then
      approved_candidates[#approved_candidates+1] = candidate
      approved_candidates[candidate] = #approved_candidates
    end
  end
  output("\n")
end


----------------------------------------------------------------
-- Calculate battles and rankings according to Schulze method --
----------------------------------------------------------------

battles = {}  -- two dimensional array
max_pro_contra = 0
for i = 1, #approved_candidates do
  battles[i] = {}
end
ranking = {}  -- mapping candidate name to ranking number and ranking number to list of candidates

function beating_weight(pro, contra)
  if pro > contra then
    return pro
  else
    return 0
  end
end

do
  local matrix = {}
  for i = 1, #approved_candidates do
    matrix[i] = {}
  end
  for i = 1, #approved_candidates do
    for j = i+1, #approved_candidates do
      local pro, contra = 0, 0
      for k = 1, #ballots do
        local ballot = ballots[k]
        local rank1 = ballot[approved_candidates[i]]
        local rank2 = ballot[approved_candidates[j]]
        if rank1 > rank2 then
          pro = pro + 1
        elseif rank2 > rank1 then
          contra = contra + 1
        end
      end
      battles[i][j] = pro
      battles[j][i] = contra
      max_pro_contra = math.max(max_pro_contra, pro)
      matrix[i][j] = beating_weight(pro, contra)
      matrix[j][i] = beating_weight(contra, pro)
    end
  end
  for i = 1, #approved_candidates do
    for j = 1, #approved_candidates do
      if i ~= j then
        for k = 1, #approved_candidates do
          if i ~= k and j ~= k then
            matrix[j][k] = math.max(matrix[j][k], math.min(matrix[j][i], matrix[i][k]))
          end
        end
      end
    end
  end
  local count = 0
  repeat
    local winners = {}
    for i = 1, #approved_candidates do
      local candidate = approved_candidates[i]
      if ranking[candidate] == nil then
        local best = true
        for j = 1, #approved_candidates do
          if i ~= j then
            local other_candidate = approved_candidates[j]
            if ranking[other_candidate] == nil and matrix[j][i] > matrix[i][j] then
              best = false
              break
            end
          end
        end
        if best then
          winners[#winners+1] = candidate
        end
      end
    end
    ranking[#ranking+1] = winners
    for i = 1, #winners do
      ranking[winners[i]] = #ranking
      count = count + 1
    end
  until count == #approved_candidates
end


--------------------
-- Output ranking --
--------------------

if #approved_candidates > 0 then
  output("Ranking:\n")
  for rank = 1, #ranking do
    local list = ranking[rank]
    for i = 1, #list do
      local candidate = list[i]
      output(padded_number(rank, #ranking), ". ", candidate, "\n")
    end
  end
  output("\n")
end


---------------------------
-- Output battle results --
---------------------------

if #approved_candidates > 1 then
  for rank = 1, #ranking do
    local list = ranking[rank]
    for i = 1, #list do
      local candidate = list[i]
      output("Direct comparison of: ", padded_number(rank, #ranking), ". ", candidate, "\n")
      for other_rank = 1, #ranking do
        local other_list = ranking[other_rank]
        for j = 1, #other_list do
          local other_candidate = other_list[j]
          if candidate ~= other_candidate then
            local pro    = battles[approved_candidates[candidate]][approved_candidates[other_candidate]]
            local contra = battles[approved_candidates[other_candidate]][approved_candidates[candidate]]
            output(padded_number(pro, max_pro_contra), ":", padded_number(contra, max_pro_contra), "  ", padded_number(other_rank, #ranking), ". ", other_candidate, "\n")
          end
        end
      end
      output("\n")
    end
  end
end

