#!/usr/bin/python
"""This is a trade verifier for the BGG Aussie Maths trade.

It runs as a CGI service and provides feedback on a variety of errors
and problems with want lists submitted to the service.
"""

import collections
import cgi
import csv
import datetime
import json
import re
import sys
import traceback
import StringIO

import read_trade_config

form = cgi.FieldStorage();

#datafile = "/usr/local/rattus.net/data/aussie-trade-jan-2012.txt"
#datafile = "/usr/local/rattus.net/data/aussie-trade-jan-2014.txt"
#datafile = "/usr/local/rattus.net/data/aussie-trade-may-2014.txt"
#datafile = "/usr/local/rattus.net/data/sydney-trade-oct-2014.txt"
#datafile = "/usr/local/rattus.net/data/cancon-trade-jan-2015.txt"
#logfile = "/usr/local/rattus.net/data/tradelogs.txt"




# logic for handling the game data

def ReadCodes(filename):
  "Read the complete list of codes in from a datafile and sort them"

  # all codes here.
  codes = {}
  # a dict mapping all valid numbers to their associated name string.
  numberlist = {}
  # a dict mapping all valid names to the list of numbers that have them.
  namelist = {}
  # a dict mapping each BGG user to a set of all the codes they
  # own, so we can check that people only do their own codes, and
  # don't miss any.
  user_codes = collections.defaultdict(list)
  # a dict mapping each valid code to the user who listed it.
  user_by_code = {}

  fh = open(filename, "r")
  # add support for skipping comment lines in our data file.
  def skipcomments(iter):
    """Skip comment lines in file (starting wih #)."""
    for line in iter:
      if line[0] == "#":
        continue
      yield line

  csvfh = csv.reader(skipcomments(fh))
  headerline = csvfh.next()

  for row in csvfh:
    rowd = dict(zip(headerline, row))

    code = rowd["mtcode"]
    codes[code] = rowd["objectname"]
    bits = code.split("-")
    numberlist[bits[0]] = bits[1]

    # since we can have duplicate names, we'll map names to a list of
    # the numbers sporting them.
    if bits[1] not in namelist:
      namelist[bits[1]] = []
    namelist[bits[1]].append(bits[0])

    username = rowd["username"].lower()
    user_codes[username].append(code)
    user_by_code[code] = username

  # convert this to a set.
  for user in user_codes:
    user_codes[user] = set(user_codes[user])

  return { "codes" : codes,
           "bynum" : numberlist,
           "byname" : namelist,
           "byuser" : user_codes,
           "userbycode" : user_by_code,
           "mincode" : min(map(int,numberlist.keys())),
           "maxcode" : max(map(int, numberlist.keys())) }

def CheckCode(code, data):
  "Check whether a supplied code is a valid code for the maths trade"
  # Let's be optimistic first.
  if code in data["codes"]:
    return True, ""

  if code == "LIMIT":
    return True, "limit"

  if re.match(r"\%[-A-Za-z0-9]+", code):
    return True, "group"

  # We'll special case cash bids here.
  m = re.match(r"\$([0-9]+)$", code)
  if m:
    if int(m.group(1)) >= 0:
      return True, "money"
    else:
      return False, "Money amounts must be non-negative"

  m = re.match(r"^([0-9]+)-([A-Z0-9]+)$", code)
  if not m:
    return False, "Unable to parse code. It must be of the form NNNNNNN-AAAAA %AAAAA or $NN"

  codenumtext = m.group(1)
  codetext = m.group(2)
  codenum = int(codenumtext)
  warning = ""
  if codenum < data["mincode"] or codenum > data["maxcode"]:
    warning = "Code number out of range %d-%d\n" % (data["mincode"],
                                                  data["maxcode"])
  elif codenumtext not in data["bynum"]:
    warning += "Code number is %s not valid" % codenumtext + "\n"
  else:
    # code number _was_ found.
    realname = data["bynum"][codenumtext]
    warning += "Code %s doesn't match name. Did you mean %s?\n" % (
      codenumtext, codenumtext + "-" + realname)

  if codetext not in data["byname"]:
    warning += "Code name %s is not valid" % codetext + "\n"
  else:
    # code text _was_ found.
    warning += ("Did you mean one of these codes for %s: " % codetext)
    warning += " ".join(data["byname"][codetext]) + " ?"

  warning = warning.strip("\n")
  return False, warning


def VerifyList(tradelist, data):
  "Verified each line of the tradelist and generate a report"

  bgguser = None

  errors = { "all" : False, "line": False }
  had_errors = False
  line_count = 0
  money_bids = 0
  limit_set = False
  report = StringIO.StringIO()

  groups_defined = set()
  group_items = {}
  groups_used = set()
  left_items = set()
  # everything that appears on the right
  right_items = set()
  # the set of all items wanted.
  wanted_items = []
  # overrides defined for the checker
  overrides = { }

  def AddError(*terms):
    print >>report, "<span class=error>", " ".join(terms), "</span>"
    errors["all"]  = True
    errors["line"] = True

  def AddInfo(*terms):
    print >>report, "<span class=info>", " ".join(terms), "</span>"



  for line in re.split(r"\r\n|\r|\n", tradelist):
    line = line.strip()
    if not(line):
      print >>report
      continue

    print >>report, cgi.escape(line)
    # comments.
    if re.match("^\s*#", line):
      # check for and parse overrides.
      m = re.match(r"\s*#\s*OVERRIDE\s+(.*)", line)
      if m:
        override_tokens = m.group(1).split()
        if not override_tokens:
          AddError("Invalid OVERRIDE line. Use '# OVERRIDE command' or "
                   "'# OVERRIDE command arg1 arg2': ",
                   cgi.escape(line.strip()))
        else:
          # defines all the valie override verbs.
          OVERRIDE_VERBS = [ "DUPSOK" ]
          if override_tokens[0] in OVERRIDE_VERBS:
            overrides[override_tokens[0]] = override_tokens[1:]
          else:
            AddError("Invalid OVERRIDE command:",
                     cgi.escape(override_tokens[0]),
                   "valid commands are:", " ".join(OVERRIDE_VERBS))
      # skip further processing of the comment line.
      continue

    # from here, this is some sort of interesting line.
    line_count += 1
    left_money = False
    right_money = False
    errors["line"] = False

    #
    # Check initial name part.
    #

    m = re.match(r"\(([^)]+)\)\s+(.*)", line)
    if not m:
      AddError("Each trade line must begin with a BGG username in () followed by at least one space.")
      continue
    if not bgguser:
      bgguser = m.group(1).lower()
    else:
      if m.group(1).lower() != bgguser:
        AddError("BGG username in () doesn't match earlier lines.")
        # nonfatal.

    line = m.group(2)
    bits = line.split(":")
    if len(bits) != 2:
      AddError("Line does not contain exactly one :")
      continue

    left_codes = bits[0].split()
    if len(left_codes) != 1:
      AddError("More than one token to the left of the :")
      continue

    #
    # Check the left hand side.
    #

    left_code = left_codes[0]
    valid, left_message = CheckCode(left_code, data)
    # TODO, check up on the various other types of code here.
    if not valid:
      AddError("On left side:", cgi.escape(left_code),
               "is invalid.", left_message)

    if not left_message:
      if (bgguser not in data["byuser"] or
          left_code not in data["byuser"][bgguser]):
        AddError("Item", cgi.escape(left_code), "was not listed by user",
                 cgi.escape(bgguser),
                 "(listed by %s)!" % data["userbycode"].get(left_code, "None"))

      if left_code in left_items:
        AddError("Item", cgi.escape(left_code), "has multiple want lists.")
      else:
        left_items.add(left_code)

    if left_message == "money":
      left_money = True

    if left_message == "group":
      if left_code in groups_defined:
        AddError("Duplication protection group", cgi.escape(left_code),
                 "defined more than once!")
      groups_defined.add(left_code)
      group_items[left_code] = []

    if left_message == "limit":
      limit_value = bits[1].split()
      limit_error = False
      if len(limit_value) != 1:
        limit_error = True
      else:
        valid, message = CheckCode(limit_value[0], data)
        if not valid or message != "money":
          limit_error = True

      if limit_error:
        AddError("A LIMIT must be followed by a dollar value")
        continue

      if limit_set:
        AddError("LIMIT was set more than once")
      limit_set = True


    #
    # Iterate through the right hand side.
    #

    all_right_codes = set()
    right_codes = bits[1].split()
    if len(right_codes) == 0:
      AddInfo("Warning. Empty want list.")

    for code in right_codes:
      safecode = cgi.escape(code)
      if code == left_code:
        AddError("You want game", safecode, "for itself.")
        continue

      valid, message = CheckCode(code, data)
      if not valid:
        AddError("On right:", safecode, "is invalid.", message)
        continue

      if not message:
        right_items.add(code)
        if left_message != "group":
          wanted_items.append((code,line_count))

      if not message and code in all_right_codes:
        AddError("Code", safecode, "appears multiple times.")
      else:
        all_right_codes.add(code)

      if message == "money":
        if right_money == True:
          AddError("Multiple dollar amounts on right side.")
        right_money = True

      if message == "group":
        if left_message == "group":
          AddError("A %GROUP was used on the left and right side.")
        groups_used.add(code)

      if message == "limit":
        AddError("You cannot have a LIMIT on the right")

    if left_money and right_money:
      AddError("Money on left and right.")
    if left_money:
      money_bids += 1

    if not errors["line"]:
      AddInfo("ok.")

  print >>report

  # we should warn differently if there are multiple money bids, vs if there
  # are just one.
  if money_bids > 1 and not limit_set:
    AddError("Error. Multiple money bids without a LIMIT will have a default LIMIT of the largest bid. Please add a LIMIT")
  if money_bids == 1 and not limit_set:
    AddInfo("Warning. One money bid made without a LIMIT.")

  if not money_bids and limit_set:
    AddInfo("Warning. A limit was set but no money bids were made. This has no effect.")

  if money_bids and limit_set and limit_value == 0:
    AddError("Money bids were made, but the limit of $0 breaks them.")

  items_listed = set(left_items)
  if (items_listed and bgguser in data["byuser"] and
      items_listed != data["byuser"][bgguser]):
    AddInfo("%s posted no want lists for the following posted items:" % bgguser,
            " ".join(data["byuser"][bgguser] - items_listed))

  unused_groups = groups_defined - groups_used
  undefined_groups = groups_used - groups_defined
  if unused_groups:
    AddInfo("Warning. Unused groups:",cgi.escape(" ".join(unused_groups)))
  if undefined_groups:
    AddError("Error. Undefined groups were used in wantlists:",
             cgi.escape(" ".join(undefined_groups)))
  left_and_right = left_items.intersection(right_items)
  if left_and_right:
    AddError("Error. Items appear both left and right sides:",
             cgi.escape(" ".join(left_and_right)))


  # check for the existence of multiple similar-looking items in want lists
  # e.g. that someone is likely missing a duplicate-protection group.

  wanted_codes = {}
  for (item, line_number) in wanted_items:
    (number,code) = item.split('-')
    # we need to disambiguate different objects with the same code here.
    full_code = (code, data["codes"][item])
    if full_code not in wanted_codes:
      wanted_codes[full_code] = { "items": [], "lines": set() }
    wanted_codes[full_code]["items"].append(item)
    wanted_codes[full_code]["lines"].add(line_number)

  for full_code in wanted_codes:
    if "DUPSOK" in overrides and full_code[0] in overrides["DUPSOK"]:
      # don't check against this code at all.
      continue
    itemlist = list(set(wanted_codes[full_code]["items"]))
    if len(itemlist) > 1 and len(wanted_codes[full_code]["lines"]) > 1:
      AddError("Error. The following similar items appear in multiple wantlists "
              "so you could be that guy who receives multiple copies in the trade. "
               "Use a duplicate protection group to fix this.",
              cgi.escape(" ".join(itemlist)))


  #  m = re.match("
  if not errors["all"]:
    print >>report
    print >>report, "<b>All lines verified okay. Please submit via geekmail to thetraderat </b>"
    all_okay = True
  else:
    print >>report
    print >>report, "<b>Some errors were found. Correct and reverify.</b>"
    all_okay = False

  return report.getvalue().replace("\n", "<br>\n"), all_okay

# core functionality here.
# TODO: make this neatly into a function sometime.

if len(sys.argv) > 1:
  argv = sys.argv[1:]
  if argv[0] == "--full":
    full_report = True
    argv = argv[1:]
  else:
    full_report = False
  if len(argv) != 2:
    print "Usage: checktrades datafile tradefile"
    sys.exit(1)

  datafile = argv[0]
  tradelist = open(argv[1], "r").read()
  print_html_report = False
  save_log = False
  print "Checking file %s:" % (argv[1]),
  config = {}
else:
  config = read_trade_config.ReadConfig()
  if not "error" in config:
    logfile = config["logfile"]
    datafile = config["datafile"]
  tradelist = form.getfirst("tradelist", "")
  print_html_report = True
  save_log = True

if tradelist:

  try:
    # TODO: rewrite all this sensibly into functions so the flow can
    # be followed.
    if "error" in config:
      raise ValueError
    if save_log:
      log = open(logfile, "a")
      log.write(datetime.datetime.now().isoformat(" ") + "\n")
      log.write(tradelist + "\n\n")
    data = ReadCodes(datafile)
    verify_message, verified_okay = VerifyList(tradelist, data)

  except:

    verify_message = "Error in verification service. Please geekmail thepackrat with details of what you tried. We apologise for the inconvenience."
    verified_okay = False

    exc_type, exc_value, exc_traceback = sys.exc_info()
    trace_lines = traceback.format_exception(exc_type, exc_value, exc_traceback)

    if save_log:
      log = open(logfile, "a")
      log.write(datetime.datetime.now().isoformat(" ") + "\n")
      log.write("ERROR\n")
      log.write(tradelist + "\n")
      log.write(''.join('!! ' + line for line in trace_lines) + "\n")
      if "error" in config:
        log.write("!! " + config["error"] + "\n")
    else:
      print ''.join('!! ' + line for line in trace_lines)
      if "error" in config:
        print "!! " + config["error"]


else:
  verify_message = "No tradelist entered."

warning_message = """
<font size=+1 color=red><b>
<br> The tool doesn't yet have the final item list for the 2012 year-end aussie
maths trade. Please check back after the codes are finalised.
</b></font>
<br>
"""

warning_message = """
<font size=+1 color=red><b>
<br> The 2012 end of year maths trade tool is just being brought up. You
may experience oddities until I'm done. </b></font>
<br>
"""


warning_message = ""

if print_html_report:

  if "trade_name" in config:
    trade_name = config["trade_name"]
  else:
    trade_name = "Demo"

  print "Content-type: text/html"
  print

  print """<html>
<head><title>BGG Maths trade list verifier</title>
<style type="text/css">
.error {
  font-weight: bold;
  color: #ff0000;
}
.info {
  font-weight: bold;
  color: #000000;
}
</style>
</head>
<body>

<h1> %s </h1>


<div> <P> The purpose of this tool is to give quick feedback on some of the
simple errors that can creep into a tradelist. Once your tradelist
verifies okay here, submit it via geekmail as normal. Report issues
via geekmail to thepackrat.
</div>

<div class="warning">
%s
</div>

<div class="report">
<tt>
%s
</tt>
<div>

<br><br>

<form method="post" action="checktrades.py">
Paste a copy of your trade list below:<br>
<textarea cols=80 rows=10 name="tradelist">
%s</textarea><br>
<input type=submit value="verify">
</form>

</body>

""" % (trade_name, warning_message, verify_message, cgi.escape(tradelist))

else:
  # when print_html_report is not set.
  if full_report:
    print warning_message
    print verify_message
  else:
    if verified_okay:
      print "Okay"
    else:
      print "Some errors found"

  if verified_okay:
    sys.exit(0)
  else:
    sys.exit(1)
