#!/usr/bin/env python3

#
# submit -- command-line submit program for submissions.
#
# Part of the DOMjudge Programming Contest Jury System and licensed
# under the GNU GPL. See README and COPYING for details.
#

import argparse
import base64
import datetime
import json
import logging
import os
import requests
import requests.utils
import stat
import sys
import time
from typing import NoReturn
try:
    import magic
except ModuleNotFoundError:
    # Ignore, magic is optional
    magic = None

# Set the default base URL to submit to (optional). It can be overridden
# by the SUBMITBASEURL environment variable or the -u/--url argument.
baseurl = ''

# Use a specific API version, set to empty string for default
# or set to the version followed by a slash to use that version
api_version = ''

# Last modified time in minutes after which to warn for submitting
# an old file.
warn_mtime_minutes = 5

# End of configurable settings
num_warnings = 0

headers = {'user-agent': f'domjudge-submit-client ({requests.utils.default_user_agent()})'}


def confirm(message: str) -> bool:
    answer = ''
    while answer not in ['y', 'n']:
        answer = input(f'{message} (y/n) ').lower()
    return answer == 'y'


def warn_user(msg: str) -> None:
    global num_warnings
    num_warnings += 1
    if args.quiet:
        logging.debug(f'user warning #{num_warnings}: {msg}')
    else:
        print(f'WARNING: {msg}!')


def usage(msg: str) -> NoReturn:
    logging.error(f'error: {msg}')
    print(f"Type '{sys.argv[0]} --help' to get help.")
    exit(1)


def error(msg: str) -> NoReturn:
    logging.error(msg)
    exit(1)


def print_if_json(text: str) -> None:
    """Print the text if it is valid JSON, and ignore otherwise"""
    try:
        data = json.loads(text)
        print(json.dumps(data, indent=2))
    except json.decoder.JSONDecodeError:
        pass


def read_contests() -> list:
    """Read all contests from the API.

    Returns:
        The contests or None if an error occurred.
    """

    try:
        data = do_api_request('contests')
    except RuntimeError as e:
        logging.warning(e)
        return []

    if not isinstance(data, list):
        logging.warning("DOMjudge's API returned unexpected JSON data for endpoint 'contests'.")
        return []

    contests = []
    for contest in data:
        if ('id' not in contest
                or 'shortname' not in contest
                or not contest['id']
                or not contest['shortname']):
            logging.warning("DOMjudge's API returned unexpected JSON data for 'contests'.")
            return []
        contests.append(contest)

    logging.info(f'Read {len(contests)} contest(s) from the API.')
    return contests


def read_languages() -> list:
    """Read all languages for the current contest from the API.

    Returns:
        The languages or None if an error occurred.
    """

    try:
        endpoint = 'contests/' + my_contest['id'] + '/languages'
        data = do_api_request(endpoint)
    except RuntimeError as e:
        logging.warning(e)
        return []

    if not isinstance(data, list):
        logging.warning("DOMjudge's API returned unexpected JSON data for endpoint 'languages'.")
        return []

    languages = []

    for item in data:
        if ('id' not in item
                or 'extensions' not in item
                or not item['id']
                or not isinstance(item['extensions'], list)
                or len(item['extensions']) == 0):
            logging.warning("DOMjudge's API returned unexpected JSON data for 'languages'.")
            return []
        language = {
            'id': item['id'],
            'name': item['name'],
            'entry_point_required': item['entry_point_required'] or False,
            'extensions': {item['id']}
        }
        language['extensions'] |= set([ext for ext in item['extensions']])
        languages.append(language)

    logging.info(f'Read {len(languages)} language(s) from the API.')

    return languages


def read_problems() -> list:
    """Read all problems for the current contest from the API.

    Returns:
        The problems or None if an error occurred.
    """

    try:
        endpoint = 'contests/' + my_contest['id'] + '/problems'
        data = do_api_request(endpoint)
    except RuntimeError as e:
        logging.warning(e)
        return []

    if not isinstance(data, list):
        logging.warning("DOMjudge's API returned unexpected JSON data for endpoint 'problems'.")
        return []

    problems = []

    for problem in data:
        if ('id' not in problem
                or 'label' not in problem
                or not problem['id']
                or not problem['label']):
            logging.warning("DOMjudge's API returned unexpected JSON data for 'problems'.")
            return []
        problems.append(problem)

    logging.info(f'Read {len(problems)} problem(s) from the API.')

    return problems


def do_api_request(endpoint: str, data=None, files=None):
    """Perform an API call to the given endpoint and return its data.

    Parameters:
        endpoint (str): the endpoint to call
        data (dict): optional data to send in the POST request
        files (list): optional files to send in the POST request

    Returns:
        The decoded endpoint contents.

    Raises:
        RuntimeError when the response is not JSON or the HTTP status code is non 2xx.
    """

    if not baseurl:
        raise RuntimeError('No baseurl set')

    url = f'{baseurl}api/{api_version}{endpoint}'

    logging.info(f'Connecting to {url}')

    try:
        if data or files:
            response = requests.post(url, data=data, files=files, headers=headers)
        else:
            response = requests.get(url, headers=headers)
    except requests.exceptions.RequestException as e:
        raise RuntimeError(e)

    logging.debug(f"API call '{endpoint}' returned:\n{response.text}")
    if response.status_code >= 300:
        print_if_json(response.text)
        if response.status_code == 401:
            raise RuntimeError('Authentication failed, please check your DOMjudge credentials in ~/.netrc.')
        else:
            raise RuntimeError(f'API request {endpoint} failed (code {response.status_code}).')

    try:
        decoded_response = json.loads(response.text)
    except json.decoder.JSONDecodeError as e:
        raise RuntimeError(f'Parsing DOMjudge\'s API output failed: {e}')

    return decoded_response


def kotlin_base_entry_point(filebase: str) -> str:
    if filebase == "":
        return "_"
    chars = list(filebase)
    for idx, c in enumerate(chars):
        if not c.isalnum():
            chars[idx] = "_"

    if chars[0].isalnum():
        chars[0] = chars[0].upper()
        filebase = "".join(chars)
    else:
        filebase = "_" + "".join(chars)

    return filebase


def get_epilog() -> str:
    """Get the epilog for the help text."""

    contests_part_one = None
    contests_part_two = None

    if contest_id:
        contests_part_one = '''For CONTEST use the ID or short name as shown in the top-right contest
    drop-down box in the web interface.'''
        contests_part_two = f"Currently defaults to '{contest_id}', pass '-c <contest_id>' to override."
    else:
        if not contests or len(contests) <= 1:
            contests_part_one = '''For CONTEST use the ID or short name as shown in the top-right contest
    drop-down box in the web interface.'''
            if contests and len(contests) == 1:
                contests_part_two = f"Currently this defaults to the only active contest '{contests[0]['shortname']}'"
        else:
            contests_part_one = 'For CONTEST use one of the following:'
            max_length = max([len(c['shortname']) for c in contests])
            for contest in contests:
                contests_part_one += f"\n    {contest['shortname']:<{max_length}} - {contest['name']}"

    if not problems:
        problem_part = 'For PROBLEM use the label as on the scoreboard.'
    else:
        problem_part = 'For PROBLEM use one of the following:'
        max_length = max([len(p['label']) for p in problems])
        for problem in problems:
            problem_part += f"\n    {problem['label']:<{max_length}} - {problem['name']}"

    if not languages:
        language_part = 'For LANGUAGE use the ID or a common extension.'
    else:
        language_part = 'For LANGUAGE use one of the following IDs or extensions:'
        max_length = max([len(lang['name']) for lang in languages])
        for language in languages:
            sorted_exts = ', '.join(sorted(language['extensions']))
            language_part += f"\n    {language['name']:<{max_length}} - {sorted_exts}"

    submit_client = sys.argv[0]
    epilog_parts = [
        "Explanation of submission options:",
        contests_part_one,
        contests_part_two,
        problem_part,
        '''When not specified, PROBLEM defaults to the first FILENAME excluding the
extension. For example, 'B.java' indicates the problem 'B'.''',
        language_part,
        '''The default for LANGUAGE is the extension of FILENAME. For example,
'B.java' indicates a Java submission.''',
        "Set URL to the base address of the webinterface without the 'team/' suffix.\n" +
        (f"The (pre)configured URL is '{baseurl}'\n" if baseurl else '') +
        "Credentials are read from ~/.netrc (see netrc(4) for details).",
        f'''Examples:

Submit problem 'b' in Java:
    {submit_client} b.java

Submit problem 'z' in C# for contest 'demo':
    {submit_client} --contest=demo z.cs

Submit problem 'e' in C++:
    {submit_client} --problem e --language=cpp ProblemE.cc

Submit problem 'hello' in C (options override the defaults from FILENAME):
    {submit_client} -p hello -l C HelloWorld.cpp

Submit multiple files (the problem and language are taken from the first):
    {submit_client} hello.java message.java''',
    ]

    return "\n\n".join(part for part in epilog_parts if part)


def do_api_print() -> None:
    """Submit to the API for printing with the given data."""

    if len(filenames) != 1:
        error('You can only print a single file')
    filename = filenames[0]

    with open(filename, 'rb') as file:
        data = {
            'original_name': filename,
            'file_contents': base64.b64encode(file.read()),
        }
    if my_language:
        data['language'] = my_language['name']
    if entry_point:
        data['entry_point'] = entry_point

    endpoint = f"printing/team"
    result = do_api_request(endpoint, data)

    if not isinstance(result, dict) or 'success' not in result:
        error('DOMjudge\'s API returned unexpected JSON data.')

    if result['success']:
        print("DOMjudge reported a successful print job.")
    else:
        # Should not happen, as the status code should've been >= 300
        print(f"DOMjudge reported a printing error: {result['output']}")


def do_api_submit() -> None:
    """Submit to the API with the given data."""

    data = {
        'problem': my_problem['id'],
        'language': my_language['id'],
    }
    if entry_point:
        data['entry_point'] = entry_point

    files = [('code[]', open(filename, 'rb')) for filename in filenames]

    endpoint = f"contests/{my_contest['id']}/submissions"
    submission = do_api_request(endpoint, data, files)

    if (not isinstance(submission, dict)
            or 'id' not in submission
            or not isinstance(submission['id'], str)):
        error('DOMjudge\'s API returned unexpected JSON data.')

    time = datetime.datetime.fromisoformat(submission['time']).strftime('%H:%M:%S')
    sid = submission['id']
    print(f"Submission received: id = s{sid}, time = {time}")
    print(f"Check {baseurl}team/submission/{sid} for the result.")


version_text = '''
submit -- part of DOMjudge
Written by the DOMjudge developers

DOMjudge comes with ABSOLUTELY NO WARRANTY.  This is free software, and you
are welcome to redistribute it under certain conditions.  See the GNU
General Public Licence for details.
'''

loglevels = {
    'DEBUG': logging.DEBUG,
    'INFO': logging.INFO,
    'WARNING': logging.WARNING,
    'ERROR': logging.ERROR,
    'CRITICAL': logging.CRITICAL,
}

# Note: we set add_help to false since we can only print the help text after
# parsing flags, since the help contains data needed from the API.
parser = argparse.ArgumentParser(
        formatter_class=argparse.RawTextHelpFormatter,
        description='Submit a solution for a problem.',
        add_help=False)
parser.add_argument('--version', action='version', version=version_text, help='output version information and exit')
parser.add_argument('-h', '--help', help='display this help and exit', action='store_true')
parser.add_argument('-c', '--contest', help='''submit for contest with ID or short name CONTEST.
    Defaults to the value of the
    environment variable 'SUBMITCONTEST'.
    Mandatory when more than one contest is active.''')
parser.add_argument('-P', '--print', help='submit the file for printing instead of submission', action='store_true')
parser.add_argument('-p', '--problem', help='submit for problem with ID or label PROBLEM', default='')
parser.add_argument('-l', '--language', help='submit in language with ID LANGUAGE', default='')
parser.add_argument('-e', '--entry_point', help='set an explicit entry_point, e.g. the java main class')
parser.add_argument('-v', '--verbose', help='increase verbosity', choices=loglevels.keys(), nargs='?', const='INFO', default='WARNING')  # NOQA
parser.add_argument('-q', '--quiet', help='suppress warning/info messages', action='store_true')
parser.add_argument('-y', '--assume-yes', help='suppress user input and assume yes', action='store_true')
parser.add_argument('-u', '--url', help='''submit to server with base address URL
    (should not be necessary for normal use)''')
parser.add_argument('filename', nargs='*', help='filename(s) to submit')

args = parser.parse_args()

verbosity = args.verbose
if args.quiet:
    verbosity = 'ERROR'

logging.basicConfig(format='%(message)s', level=loglevels[verbosity])
logging.info(f'set verbosity to {verbosity}')

problem_id = args.problem
language_id = args.language
entry_point = args.entry_point

if args.url:
    baseurl = args.url
elif 'SUBMITBASEURL' in os.environ:
    baseurl = os.environ['SUBMITBASEURL']
    logging.warning(f"Using '{baseurl}' as defined in the 'SUBMITBASEURL' environment variable. "
                    + "Pass '-u <url>' to override.")
# Make sure that baseurl terminates with a '/' for later concatenation.
if baseurl and baseurl[-1:] != '/':
    baseurl += '/'

if args.contest:
    contest_id = args.contest
elif 'SUBMITCONTEST' in os.environ:
    contest_id = os.environ['SUBMITCONTEST']
    logging.warning(f"Using '{contest_id}' as defined in the 'SUBMITCONTEST' environment variable. "
                    + "Pass '-c <contest_id>' to override.")
else:
    contest_id = ''

contests = read_contests() if baseurl else None
if not contests and not args.help:
    logging.warning('Could not obtain active contests.')

my_contest: dict = {}
my_language: dict = {}
my_problem: dict = {}

if not contest_id:
    if not contests:
        if not args.help:
            warn_user('No active contests found (and no contest specified)')
    elif len(contests) == 1:
        my_contest = contests[0]
    else:
        shortnames = ', '.join([c['shortname'] for c in contests])
        warn_user(f"Multiple active contests found, please specify one of {shortnames}")
elif contests:
    contest_id = contest_id.lower()
    for contest in contests:
        if contest['id'].lower() == contest_id or contest['shortname'].lower() == contest_id:
            my_contest = contest
            break

languages: list = []
problems: list = []
if my_contest and baseurl:
    if not args.print and 'allow_submit' in my_contest and not my_contest['allow_submit']:
        warn_user('Submissions for contest (temporarily) disabled')
        exit(1)
    languages = read_languages()
    problems = read_problems()

parser.epilog = get_epilog()

if args.help:
    if 'BATS_VERSION' in os.environ:
        # print_help adds line breaks depending on the number of available columns.
        # To make it deterministic under test, we set it to 100 here if we are testing.
        os.environ['COLUMNS'] = '100'
    parser.print_help()
    exit(0)

if not baseurl:
    usage('No contest url specified, pass it as --url or set as SUBMITBASEURL environment variable.')

if not my_contest:
    usage('No (valid) contest specified, pass it as --contest or set as SUBMITCONTEST environment variable.')

if not languages:
    logging.warning('Could not obtain language data.')

if not problems:
    logging.warning('Could not obtain problem data.')

if len(args.filename) == 0:
    usage('No file(s) specified.')

# Process all source files
filenames = []
for index, filename in enumerate(args.filename, 1):
    # Ignore doubly specified files
    if filename in filenames:
        logging.debug(f"Ignoring doubly specified file `{filename}'.")
        continue

    # Stat file and do some validation checks
    try:
        st = os.stat(filename)
    except FileNotFoundError:
        usage(f"Cannot find file `{filename}'.")

    logging.debug(f"submission file {index}: `{filename}'")

    # Do some checks on submission file and warn user
    if not stat.S_ISREG(st.st_mode):
        warn_user(f"`{filename}' is not a regular file")
    if not st.st_mode & stat.S_IRUSR:
        warn_user(f"`{filename}' is not readable")
    if st.st_size == 0:
        warn_user(f"`{filename}' is empty")

    file_age = (time.time() - st.st_mtime) / 60
    if file_age > warn_mtime_minutes:
        warn_user(f"`{filename}' has not been modified for {int(file_age)} minutes")

    if magic:
        m = magic.from_file(filename, mime=True)
        if m[:5] != 'text/':
            warn_user(f"`{filename}' is detected as binary/data")

    filenames.append(filename)

# Try to parse problem and language from first filename.

filebase = os.path.basename(filenames[0])

ext = ""
if '.' in filebase:
    dot = filebase.rfind('.')
    ext = filebase[dot+1:]
    filebase = filebase[:dot]

    if not problem_id:
        problem_id = filebase
    if not language_id:
        language_id = ext

# Check for languages matching file extension.
language_id = language_id.lower()
for language in languages:
    for extension in language['extensions']:
        if extension.lower() == language_id:
            my_language = language
            break
    if my_language:
        break

if not my_language and not args.print:
    usage('No known language specified or detected.')

# Check for problem matching ID or label.
problem_id = problem_id.lower()
for problem in problems:
    if problem['id'].lower() == problem_id or problem['label'].lower() == problem_id:
        my_problem = problem
        break

if not my_problem and not args.print:
    usage('No known problem specified or detected.')

if not args.print:
    # Guess entry point if not already specified.
    if not entry_point and my_language['entry_point_required']:
        if my_language['name'] == 'Java':
            entry_point = filebase
        elif my_language['name'] == 'Kotlin':
            entry_point = kotlin_base_entry_point(filebase) + "Kt"
        elif my_language['name'] == 'Python 3':
            entry_point = filebase + "." + ext

    if not entry_point and my_language['entry_point_required']:
        error('Entry point required but not specified nor detected.')

logging.debug(f"contest is `{my_contest['shortname']}'")
if not args.print:
    logging.debug(f"problem is `{my_problem['label']}'")
logging.debug(f"language is `{my_language.get('name', '<None>')}'")
if not args.print:
    logging.debug(f"entry_point is `{entry_point or '<None>'}'")
logging.debug(f"url is `{baseurl}'")

if args.print:
    do_api_print()
    exit(0)

if not args.assume_yes:
    print('Submission information:')
    if len(filenames) == 1:
        print(f'  filename:    {filenames[0]}')
    else:
        print(f'  filenames:   {" ".join(filenames)}')
    print(f"  contest:     {my_contest['shortname']}")
    print(f"  problem:     {my_problem['label']}")
    print(f"  language:    {my_language['name']}")
    if entry_point:
        print(f'  entry point: {entry_point}')
    print(f'  url:         {baseurl}')

    if num_warnings > 0:
        print('There are warnings for this submission!\a')

    if not confirm('Do you want to continue?'):
        error('submission aborted by user')

do_api_submit()
