"""Provide access to the scenarios."""

# NOTE: Disable "DownloadedFile has no 'index' member" warnings.
#pylint: disable=no-member

import os
import json
import urllib.request
import base64
import re
import time
import math
import hashlib
import logging

from flask import request, render_template, make_response, jsonify, abort
from PIL import Image, ImageOps

from vasl_templates.webapp import app
from vasl_templates.webapp.downloads import DownloadedFile
from vasl_templates.webapp.vassal import VassalShim
from vasl_templates.webapp.utils import TempFile, \
    get_month_name, make_formatted_day_of_month, friendly_fractions, parse_int, \
    trim_image, get_image_data, remove_alpha_from_image

# ---------------------------------------------------------------------

def _build_asa_scenario_index( df, new_data, logger ):
    """Build the ASL Scenario Archive index."""
    # parse the scenario index
    index = {
        scenario["scenario_id"]: scenario
        for scenario in new_data["scenarios"]
    }
    # install the results
    df.index = index
    df.generated_at = new_data.get( "_generatedAt_" )
    if logger:
        logger.debug( "Loaded the ASL Secenario Archive index: #scenarios=%d", len(df.index) )
        logger.debug( "- Generated at: %s", new_data.get( "_generatedAt_", "n/a" ) )

_asa_scenarios = DownloadedFile( "ASA", 6, # nb: TTL = #hours
    "asl-scenario-archive.json",
    "https://vasl-templates.org/services/asl-scenario-archive/scenario-index.json",
    _build_asa_scenario_index,
    extra_args = { "index": None }
)

# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -

def _build_roar_scenario_index( df, new_data, logger ):
    """Build the ROAR scenario index."""
    # parse the scenario index
    index, title_matching, id_matching = {}, {}, {}
    for roar_id,scenario in new_data.items():
        if roar_id.startswith( "_" ):
            continue
        scenario[ "roar_id" ] = roar_id
        index[ roar_id ] = scenario
        _update_roar_matching_index( title_matching, scenario.get("name"), roar_id )
        _update_roar_matching_index( id_matching, scenario.get("scenario_id"), roar_id )
    # install the results
    df.index, df.title_matching, df.id_matching = index, title_matching, id_matching
    df.generated_at = new_data.get( "_generatedAt_" )
    if logger:
        logger.debug( "Loaded the ROAR scenario index: #scenarios=%d", len(df.index) )
        logger.debug( "- Generated at: %s", new_data.get( "_generatedAt_", "n/a" ) )
        logger.debug( "- Last updated: %s", new_data.get( "_lastUpdated_", "n/a" ) )
        logger.debug( "- # playings:   %s", str( new_data.get( "_nPlayings_", "n/a" ) ) )

def _update_roar_matching_index( index, val, roar_id ):
    """Update the index that will be used for matching ROAR scenarios."""
    if not val:
        return
    key = _make_roar_matching_key( val )
    if key not in index:
        index[ key ] = set()
    index[ key ].add( roar_id )

def _make_roar_matching_key( val ):
    """Generate a key value that will be used to match ROAR scenarios."""
    if not val:
        return val
    return re.sub( "[^a-z0-9]", "", val.lower() )

_roar_scenarios = DownloadedFile( "ROAR", 6, # nb: TTL = #hours
    "roar-scenario-index.json",
    "https://vasl-templates.org/services/roar/scenario-index.json",
    _build_roar_scenario_index,
    extra_args = { "index": None }
)

# ---------------------------------------------------------------------

@app.route( "/scenario-index" )
def get_scenario_index():
    """Return the scenario index."""

    def add_field( entry, key, val ): #pylint: disable=missing-docstring
        if val:
            entry[ key ] = val
    def make_entry( scenario ):
        """Make an entry for the scenario index."""
        entry = { "scenario_id": scenario["scenario_id"] }
        add_field( entry, "scenario_name", _make_scenario_name( scenario ) )
        add_field( entry, "scenario_display_id", scenario.get( "sc_id" ) )
        add_field( entry, "scenario_location", scenario.get( "scen_location" ) )
        add_field( entry, "scenario_date", _parse_date( scenario.get( "scen_date" ) ) )
        add_field( entry, "publication_name", scenario.get( "pub_name" ) )
        add_field( entry, "publication_id", scenario.get( "pub_id" ) )
        add_field( entry, "publication_date", _parse_date( scenario.get( "published_date" ) ) )
        add_field( entry, "publisher_name", scenario.get( "publisher_name" ) )
        add_field( entry, "publisher_id", scenario.get( "publisher_id" ) )
        return entry

    # generate the scenario index
    with _asa_scenarios:
        if _asa_scenarios.index is None:
            if  _asa_scenarios.error_msg:
                return _make_not_available_response(
                    "Couldn't get the scenario index.", _asa_scenarios.error_msg
                )
            else:
                return _make_not_available_response(
                    "Please wait, the scenario index is still downloading.", None
                )
        etag = hashlib.md5( json.dumps( _asa_scenarios.index ).encode( "utf-8" ) ).hexdigest()
        if request.headers.get( "If-None-Match" ) == etag:
            return "Not Modified", 304
        resp = make_response( jsonify( [
            make_entry( scenario )
            for scenario in _asa_scenarios.index.values()
        ] ) )
        resp.headers["ETag"] = etag
        return resp

@app.route( "/roar/scenario-index" )
def get_roar_scenario_index():
    """Return the ROAR scenario index."""
    with _roar_scenarios:
        if _roar_scenarios.index is None:
            if _roar_scenarios.error_msg:
                return _make_not_available_response(
                    "Couldn't get the ROAR scenarios.", _roar_scenarios.error_msg
                )
            else:
                return _make_not_available_response(
                    "Please wait, the ROAR scenarios are still downloading.", None
                )
        etag = hashlib.md5( json.dumps( _roar_scenarios.index ).encode( "utf-8" ) ).hexdigest()
        if request.headers.get( "If-None-Match" ) == etag:
            return "Not Modified", 304
        resp = make_response( jsonify( _roar_scenarios.index ) )
        resp.headers["ETag"] = etag
        return resp

def _make_not_available_response( msg, msg2 ):
    """Generate a "not available" response."""
    resp = { "warning": msg }
    if msg2:
        resp[ "message" ] = msg2
    return jsonify( resp )

# ---------------------------------------------------------------------

@app.route( "/scenario/<scenario_id>" )
def get_scenario( scenario_id ): #pylint: disable=too-many-locals
    """Return a scenario."""

    # get the parameters
    roar_override = request.args.get( "roar" )

    # get the basic scenario information
    scenario, args = _do_get_scenario( scenario_id )
    args[ "scenario_date_iso" ] = _parse_date_iso( scenario.get( "scen_date" ) )
    args[ "defender_name" ] = scenario.get( "defender" )
    args[ "attacker_name" ] = scenario.get( "attacker" )
    args = { k.lower(): v for k,v in args.items() }

    # get the number of turns
    nturns = scenario.get( "max_turns" ) or scenario.get( "min_turns" )
    if nturns:
        args[ "scenario_turns" ] = nturns

    def get_win_score( key ):
        """Get a player's win percentage."""
        nWins = parse_int( playings.get( key+"_wins" ), -1 )
        if nWins < 0:
            return None
        score = 100 * nWins / nGames
        return int( score + 0.5 )

    # get any files available for download
    downloads = {}
    keys = { "vt_setup": "vaslTemplates", "vasl_setup": "vaslTemplateSetups", "screenshot": "templateImages" }
    for key, ftype in keys.items():
        for entry in scenario.get( ftype, [] ):
            fname = os.path.basename( entry.get( "url", "" ) )
            pos = fname.find( "|" )
            if pos < 0:
                continue
            fkey = fname[:pos]
            if fkey not in downloads:
                downloads[ fkey ] = {
                    "user": entry.get( "user" ),
                    "timestamp": entry.get( "created" ),
                }
            downloads[ fkey ][ key ] = entry.get( "url" )
    downloads = sorted( downloads.values(), key=lambda u: u["timestamp"], reverse=True )
    if downloads:
        args[ "downloads" ] = [
            d for d in downloads
            if "vt_setup" in d or "vasl_setup" in d
        ]

    # get the map previews
    map_images = []
    for fgroup in downloads:
        if "screenshot" in fgroup:
            map_images.append( fgroup )
    for map_image in scenario.get( "mapImages", [] ):
        map_images.append( {
            "screenshot": map_image.get( "url" ),
            "user": map_image.get( "user" ),
            "timestamp": map_image.get( "created" ),
        } )
    if map_images:
        args[ "map_images" ] = map_images

    # get the ASL Scenario Archive playings
    playings = scenario.get( "playings", [ {} ] )[ 0 ]
    nGames = parse_int( playings.get( "totalGames" ), 0 )
    if playings and nGames > 0:
        # NOTE: The player names are display names, only shown in the balance graphs,
        # so it doesn't matter if we know about them or not.
        args[ "balance" ] = [ {
            "name": scenario.get( "defender" ),
            "wins": playings.get( "defender_wins" ),
            "percentage": get_win_score( "defender" )
        }, {
            "name": scenario.get( "attacker" ),
            "wins": playings.get( "attacker_wins" ),
            "percentage": get_win_score( "attacker" )
        } ]

    # try to match the scenario with one in ROAR
    roar_id = None
    if roar_override == "auto-match":
        matches = _match_roar_scenario( scenario )
        if matches:
            roar_id = matches[0][ "roar_id" ]
    else:
        roar_id = roar_override
    if roar_id:
        args[ "roar" ] = _get_roar_info( roar_id )

    return jsonify( args )

def _do_get_scenario( scenario_id ):
    """Return the basic details for the specified scenario."""
    scenario = _get_scenario( scenario_id )
    url_template = app.config[ "ASA_SCENARIO_URL" ]
    scenario_url = url_template.replace( "{ID}", scenario_id )
    return scenario, {
        "SCENARIO_ID": scenario_id,
        "SCENARIO_URL": scenario_url,
        "SCENARIO_NAME": _make_scenario_name( scenario ),
        "SCENARIO_DISPLAY_ID": scenario.get( "sc_id" ),
        "SCENARIO_LOCATION": scenario.get( "scen_location" ),
        "SCENARIO_DATE": _parse_date( scenario.get( "scen_date" ) ),
        "THEATER": scenario.get( "theatre" ),
        "DEFENDER_DESC": scenario.get( "def_desc" ),
        "ATTACKER_DESC": scenario.get( "att_desc" ),
    }

def _match_roar_scenario( scenario ):
    """Try to match the scenario with a ROAR scenario."""

    def get_result_count( scenario ):
        """Get the number of playings for a ROAR scenario."""
        results = scenario.get( "results", [] )
        return sum( r[1] for r in results )

    with _roar_scenarios:
        if not getattr( _roar_scenarios, "index", None ):
            # NOTE: We can get here if there was a problem downloading the ROAR scenarios.
            return []
        # try to match by scenario title
        title = scenario.get( "title" )
        if not title:
            return None
        matches = _roar_scenarios.title_matching.get( _make_roar_matching_key( title ) )
        if not matches:
            return []
        elif len( matches ) == 1:
            # there was exactly one match - return it
            roar_id = next( iter( matches ) )
            return [ _roar_scenarios.index[ roar_id ] ]
        else:
            # we found multiple scenarios with the same title, filter by ID
            matches2 = _roar_scenarios.id_matching.get( _make_roar_matching_key( scenario.get("sc_id") ), set() )
            if matches2:
                matches = matches.intersection( matches2 )
            matches = [ _roar_scenarios.index[m] for m in matches ]
            matches.sort( key=get_result_count, reverse=True )
            return matches

def _get_roar_info( roar_id ):
    """Get the information for the specified ROAR scenario."""

    def get_balance( player_no ):
        """Get a player's balance stats."""
        # NOTE: The player names are display names, only shown in the balance graphs,
        # so it doesn't matter if we know about them or not.
        balance = {
            "name": playings[ player_no ][0],
            "wins": playings[ player_no ][1]
        }
        if nGames > 0:
            balance[ "percentage" ] = int( 100 * playings[player_no][1] / nGames + 0.5 )
        return balance

    with _roar_scenarios:

        # find the ROAR scenario
        index = _roar_scenarios.index or {}
        scenario = index.get( roar_id )
        if not scenario:
            abort( 404 )

        # return the scenario details
        results = {
            "scenario_id": roar_id,
            "scenario_display_id": scenario.get( "scenario_id" ),
            "name": scenario.get( "name" ),
            "url": scenario.get( "url" )
        }
        playings = scenario.get( "results" )
        if playings:
            nGames = playings[0][1] + playings[1][1]
            results[ "balance" ] = [ get_balance(0), get_balance(1) ]

        return results

# ---------------------------------------------------------------------

@app.route( "/scenario-card/<scenario_id>" )
def get_scenario_card( scenario_id ): #pylint: disable=too-many-branches
    """Return a scenario card (HTML)."""

    # get the arguments
    brief_mode = request.args.get( "brief" )

    # find the specified scenario
    scenario, args = _do_get_scenario( scenario_id )

    # prepare the template parameters
    args[ "DESIGNER" ] = scenario.get( "author" )
    args[ "PUBLICATION" ] = scenario.get( "pub_name" )
    args[ "PUBLISHER" ] = scenario.get( "publisher_name" )
    args[ "PUBLICATION_DATE" ] = _parse_date( scenario.get( "published_date" ) )
    args[ "PREV_PUBLICATION" ] = scenario.get( "prior_publication" )
    args[ "REVISED_PUBLICATION" ] = scenario.get( "revision" )
    args[ "OVERVIEW" ] = scenario.get( "overview" )
    if brief_mode:
        args[ "OVERVIEW_BRIEF" ] = _make_brief_overview( scenario.get( "overview" ) )
    args[ "DEFENDER_NAME" ] = scenario.get( "defender" )
    args[ "ATTACKER_NAME" ] = scenario.get( "attacker" )
    args[ "BOARDS" ] = ", ".join( str(m) for m in scenario.get("maps",[]) )
    overlays = ", ".join( str(o) for o in scenario.get("overlays",[]) )
    if overlays.upper() == "NONE":
        overlays = None
    if overlays:
        args[ "OVERLAYS" ] = overlays
    args[ "EXTRA_RULES" ] = scenario.get( "misc" )
    args[ "ERRATA" ] = scenario.get( "errata" )

    # prepare the template parameters
    if scenario.get( "pub_id" ):
        url_template = app.config[ "ASA_PUBLICATION_URL" ]
        args[ "PUBLICATION_URL" ] = url_template.replace( "{ID}", scenario["pub_id"] )
    if scenario.get( "publisher_id" ):
        url_template = app.config[ "ASA_PUBLISHER_URL" ]
        args[ "PUBLISHER_URL" ] = url_template.replace( "{ID}", scenario["publisher_id"] )
    playing_time = scenario.get( "time_to_play", "0" )
    if not str( playing_time ).startswith( "0" ):
        args[ "PLAYING_TIME" ] = friendly_fractions( playing_time, "hour", "hours" )

    # prepare the turn count
    min_turns = scenario.get( "min_turns", "0" )
    max_turns = scenario.get( "max_turns", "0" )
    if min_turns != "0":
        if max_turns in ("0",min_turns):
            args[ "TURN_COUNT" ] = friendly_fractions( min_turns, "turn", "turns" )
        elif max_turns != "0":
            args[ "TURN_COUNT" ] = "{}-{} turns".format( friendly_fractions(min_turns), friendly_fractions(max_turns) )

    # prepare any info icons
    icons = {}
    if scenario.get( "oba" ) in ("D","B"):
        icons[ "DEFENDER_OBA" ] = True
    if scenario.get( "oba" ) in ("A","B"):
        icons[ "ATTACKER_OBA" ] = True
    if scenario.get( "night" ) == "1":
        icons[ "IS_NIGHT" ] = True
    if scenario.get( "aslsk" ) == "1":
        icons[ "IS_ASLSK" ] = True
    if scenario.get( "deluxe" ) == "1":
        icons[ "IS_DELUXE" ] = True
    if icons:
        args[ "ICONS" ] = icons

    # prepare the lat/long co-ordinates
    is_valid_coord = lambda val: val and val != "-99.99"
    if is_valid_coord( scenario.get("gps_lat") ) and is_valid_coord( scenario.get("gps_long") ):
        url_template = app.config.get( "MAP_URL", "https://maps.google.com/maps?q={LAT},{LONG}&z=4&output=embed" )
        args["MAP_URL"] = url_template.replace( "{LAT}", scenario["gps_lat"] ).replace( "{LONG}", scenario["gps_long"] )

    # process the template and return the generated HTML
    return render_template( "scenario-card.html", **args )

def _make_brief_overview( content ):
    """Truncate the scenario overview."""
    if not content:
        return None
    threshold = parse_int( app.config.get( "BRIEF_CONTENT_THRESHOLD" ), 200 )
    if threshold <= 0 or len(content) < threshold:
        return None
    regex = re.compile( "[.?!]+" )
    mo = regex.search( content, threshold )
    if not mo:
        return content[:threshold] + "..."
    val = content[ : mo.start() + len(mo.group()) ]
    if val == content:
        return None
    return val

# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -

def _get_scenario( scenario_id ):
    """Get the specified scenario."""
    with _asa_scenarios:
        index = _asa_scenarios.index or {}
        scenario = index.get( scenario_id )
        if not scenario:
            abort( 404 )
        return scenario

def _make_scenario_name( scenario ):
    """Get the scenario's name."""
    return scenario.get( "title" ) or "Untitled scenario (#{})".format( scenario["scenario_id"] )

# ---------------------------------------------------------------------

@app.route( "/prepare-asa-upload", methods=["POST"] )
def prepare_asa_upload(): #pylint: disable=too-many-locals
    """Prepare files for upload to the ASL Scenario Archive."""

    # parse the request
    vsav_data = request.json[ "vsav_data" ]
    vsav_filename = request.json[ "filename" ]

    # initialize
    start_time = time.time()
    logger = logging.getLogger( "prepare_asa_upload" )

    try:

        # get the VSAV data (we do this inside the try block so that the user gets shown
        # a proper error dialog if there's a problem decoding the base64 data)
        vsav_data = base64.b64decode( vsav_data )
        logger.info( "Preparing VSAV (#bytes=%d): %s", len(vsav_data), vsav_filename )

        with TempFile() as input_file:

            # save the VSAV data in a temp file
            input_file.write( vsav_data )
            input_file.close( delete=False )
            fname = app.config.get( "PREPARE_ASA_UPLOAD_INPUT" ) # nb: for diagnosing problems
            if fname:
                logger.debug( "Saving a copy of the VSAV data: %s", fname )
                with open( fname, "wb" ) as fp:
                    fp.write( vsav_data )

            # prepare the files to be uploaded
            with TempFile() as stripped_vsav_file, TempFile() as screenshot_file:

                # run the VASSAL shim to prepare the VSAV file
                stripped_vsav_file.close( delete=False )
                screenshot_file.close( delete=False )
                vassal_shim = VassalShim()
                vassal_shim.prepare_asa_upload(
                    input_file.name, stripped_vsav_file.name, screenshot_file.name
                )

                # read the stripped VSAV data
                with open( stripped_vsav_file.name, "rb" ) as fp:
                    stripped_vsav = fp.read()
                stripped_vsav_file.save_copy(
                    app.config.get( "PREPARE_ASA_UPLOAD_STRIPPED_VSAV" ),
                    logger, "stripped VSAV"
                )

                # read the screenshot image
                if os.path.getsize( screenshot_file.name ) == 0:
                    # NOTE: The VASSAL shim sometimes crashes while trying to generate a screenshot :-(
                    screenshot_data = None
                else:
                    # NOTE: These screenshots are used as map preview images on the ASL Scenario Archive
                    # web site (and by us, as well), so we want to optimize them for size.
                    # NOTE: I tried changing the PNG from 24-bit RGB to using a palette:
                    #   img.convert( "P", palette=Image.ADAPTIVE/WEB )
                    # but since PNG is a lossless format, the benefits are minimal. Also, weird things happen
                    # if we do this before shrinking the image, which makes calculating the ratio tricky.
                    # clean up the original screenshot
                    img = trim_image( screenshot_file.name )
                    img = remove_alpha_from_image( img )
                    # get the image data
                    def save_image( img ): #pylint: disable=missing-docstring
                        quality = parse_int( app.config.get( "ASA_SCREENSHOT_QUALITY" ), 50 )
                        return get_image_data( img, format="JPEG", quality=quality, optimize=True, subsampling=0 )
                    screenshot_data = save_image( img )
                    # resize it to (roughly) the maximum allowed size
                    max_size = parse_int( app.config.get( "ASA_MAX_SCREENSHOT_SIZE" ), 200 ) * 1024
                    if len(screenshot_data) > max_size:
                        ratio = math.sqrt( float(max_size) / len(screenshot_data) )
                        img = img.resize( ( int(img.width * ratio), int(img.height * ratio) ), Image.ANTIALIAS )
                    # add a border
                    border_size = parse_int( app.config.get( "ASA_SCREENSHOT_BORDER_SIZE" ), 5 )
                    img = ImageOps.expand( img, border_size, (255,255,255,255) )
                    # get the final image data
                    screenshot_data = save_image( img )

                # save a copy of the screenshot image
                fname = app.config.get( "PREPARE_ASA_UPLOAD_SCREENSHOT" )
                if fname:
                    if screenshot_data:
                        logger.debug( "Saving a copy of the generated screenshot: %s", fname )
                        with open( fname, "wb" ) as fp:
                            fp.write( screenshot_data )
                    else:
                        if os.path.isfile( fname ):
                            os.unlink( fname )

    except Exception as ex: #pylint: disable=broad-except

        return VassalShim.translate_vassal_shim_exception( ex, logger )

    # return the results
    logger.info( "Prepared the VSAV file OK: elapsed=%.3fs", time.time()-start_time )
    results = {
        "filename": vsav_filename,
        "stripped_vsav": base64.b64encode( stripped_vsav ).decode( "utf-8" ),
    }
    if screenshot_data:
        results[ "screenshot" ] = base64.b64encode( screenshot_data ).decode( "utf-8" )
    return jsonify( results )

# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -

@app.route( "/on-successful-asa-upload/<scenario_id>" )
def on_successful_asa_upload( scenario_id ):
    """Update the local scenario index after a successful upload."""

    if app.config.get( "DISABLE_LOCAL_ASA_INDEX_UPDATES" ):
        return jsonify( { "status": "ok" } )

    # download the specified scenario
    url = app.config["ASA_GET_SCENARIO_URL"].replace( "{ID}", scenario_id )
    try:
        with urllib.request.urlopen( url ) as resp:
            new_scenario = json.loads(
                resp.read().decode( "utf-8" )
            )
    except Exception as ex: #pylint: disable=broad-except
        msg = str( getattr(ex,"reason",None) or ex )
        return jsonify( { "status": "error", "message": msg } )

    # update the local index
    if os.path.isfile( _asa_scenarios.cache_fname ):
        # NOTE: Since the downloaded index file contains a *list* of scenarios, we append the new scenario info
        # to the end of that list, and then the next time the program starts and reads the list into a dict,
        # the most-recent version is the one that will ultimately be used. This lets us identify these temporary changes
        # that have been made to the cached index file (which will be overwritten when we download a fresh copy).
        # This is not particularly efficient, but it won't happen too often.
        with open( _asa_scenarios.cache_fname, "r", encoding="utf-8" ) as fp:
            data = json.load( fp )
        data["scenarios"].append( new_scenario )
        with open( _asa_scenarios.cache_fname, "w", encoding="utf-8" ) as fp:
            json.dump( data, fp )

    # update the in-memory scenario index
    with _asa_scenarios:
        _asa_scenarios.index[ scenario_id ] = new_scenario

    return jsonify( { "status": "ok" } )

# ---------------------------------------------------------------------

_last_asa_upload = None

@app.route( "/test-asa-upload/<int:scenario_id>", methods=["POST"] )
def test_asa_upload( scenario_id ):
    """A test endpoint that can be used to simulate ASL Scenario Archive uploads."""

    # initialize
    logger = logging.getLogger( "test_asa_upload" )

    def save_file( key, asa_upload, convert=None ):
        """Save a file that has been uploaded to us."""
        f = request.files.get( key )
        if not f:
            logger.info( "- %s: not present.", key )
            return
        data = f.read()
        asa_upload[ key ] = convert(data) if convert else data
        logger.info( "- %s: %s (%s) ; #bytes=%d", key, f.filename, f.content_type, len(data) )
        fname = app.config.get( "SAVE_ASA_UPLOAD_" + key.upper() )
        if fname:
            with open( fname, "wb" ) as fp:
                fp.write( data )
            logger.info( "  - Saved to: %s", fname )

    def make_resp( fname ):
        """Generate a response."""
        dname = os.path.join( os.path.dirname(__file__), "tests/fixtures/asa-responses/" )
        fname = os.path.join( dname, "{}.json".format( fname ) )
        with open( fname, "r", encoding="utf-8" ) as fp:
            return jsonify( json.load( fp ) )

    # simulate a slow response
    delay = parse_int( app.config.get( "ASA_UPLOAD_DELAY" ), 0 )
    if delay > 0:
        time.sleep( delay )

    # parse the request
    user_name = request.args.get( "user" )
    if not user_name:
        return make_resp( "missing-user-name" )
    api_token = request.args.get( "token" )
    if not api_token:
        return make_resp( "missing-token" )
    if api_token == "incorrect-token":
        return make_resp( "incorrect-token" )

    # process the request
    logger.info( "ASA upload: id=%s ; user=\"%s\" ; token=\"%s\"", scenario_id, user_name,api_token )
    asa_upload = {}
    save_file( "vasl_setup", asa_upload )
    save_file( "vt_setup", asa_upload, lambda v: json.loads(v) ) #pylint: disable=unnecessary-lambda
    save_file( "screenshot", asa_upload )
    if asa_upload:
        asa_upload.update( { "user": user_name, "token": api_token } )
        global _last_asa_upload
        _last_asa_upload = asa_upload

    return make_resp( "ok" )

# ---------------------------------------------------------------------

@app.route( "/scenario/nat-report" )
def scenario_nat_report():
    """Generate the scenario nationalities report (for testing porpoises)."""
    return render_template( "scenario-nat-report.html" )

# ---------------------------------------------------------------------

def _parse_date( val ):
    """Parse a date string."""
    parts = _split_date_parts( val )
    if not parts:
        return None
    return "{} {}, {}".format(
        make_formatted_day_of_month( parts[0] ),
        get_month_name( parts[1] ),
        parts[2]
    )

def _parse_date_iso( val ):
    """Parse a date string."""
    parts = _split_date_parts( val )
    if not parts:
        return None
    return "{:04}-{:02}-{:02}".format( parts[2], parts[1], parts[0] )

def _split_date_parts( val ):
    """Split a date into its component parts."""
    if val is None:
        return None
    mo = re.search( r"^(\d{4})-(\d{2})-(\d{2})", val )
    if not mo:
        return None
    if mo.group(1) == "1901":
        return None # nb: 1901-01-01 seems to be used as a "invalid date" marker
    parts = [ int(mo.group(3)), int(mo.group(2)), int(mo.group(1)) ]
    if parts == [ 0, 0, 0, ]:
        return None # nb: 0000-00-00 also seems to be used as an "invalid date" marker
    return parts
