#!/usr/bin/env python3

### VERY MUCH PYTHON 3 !!!


"""
Example for aiohttp.web basic server
Uses a background timer to read from a file to update a shared datastructure
Because it's going to be used as a simulator

Made available under the MIT license as follows:

Copyright 2017 Brian Bulkowski brian@bulkowski.org

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.

"""
import sys
if sys.version_info[0] < 3:
    raise "Must be using Python 3"

import threading
import time
import datetime
import os

import itertools
import copy

import json
import argparse

import traceback

import asyncio
import textwrap

from aiohttp import web

from random import randint



# Relay class 
class Relay:
    def __init__(self):
        # 1 is "NO and NC", 0 is "Flipped"
        self.state = 0

        # 0, 1, 2, 3 are possible
        # 1 = relay is NC /NO when portal is controlled, reversed when neutral
        # 2 = relay is reversed when portal is controlled, NO/NC when neutral
        # 3 = relay is NC /NO when portal is controlled, reversed for 3 seconds when faction changes, then reverts to NO/NC
        # 4 = relay closed when portal is controlled, reversed for 1.5 seconds
        self.mode = 0


# todo: since a mod has an owner, should make it a class as well, for parallelism sake


# Resonator class... because portals have more than one resonator

class Resonator:

    valid_positions = [ "E", "NE", "N", "NW", "W", "SW", "S", "SE" ]
    level_energy = [0, 10, 15, 20, 25, 30, 40, 50, 60]

    def __init__(self, position, values=None ):
        # print ("Resonator create: position ",position)
        self.position = position
        if values == None:
            self.level = 0
            self.health = 0
            self.distance = 0
            # may not update position, not a value
            self.owner = ""
        else:
            self.level = int(values.get("level",0))
            self.health = int(values.get("health",0))
            self.distance = int(values.get("distance",0))
            self.owner = str(values.get("owner", ""))

            if (self.level == 0) or (self.health == 0):
                self.level = 0
                self.health = 0
                self.distance = 0
                # note: position does not change
                self.owner = ""

        # print ("Resontaor level: ",self.level)

    def check(self):
        if type(self.level) is not int:
            print("bad level type ",type(self.level))
            return False
        if self.level < 0 or self.level > 8:
            print("bad level value ",self.level)
            return False
        if type(self.health) is not int:
            print("bad level health type ",type(self.health))
            return False
        if self.health < 0 or self.health > 100:
            print("bad level value ",self.health)
            return False
        if type(self.position) is not str:
            print("bad position type ",type(self.position))
            return False
        if self.position not in self.valid_positions:
            print("bad position: ",self.position)
            return False
        if type(self.distance) is not int:
            print("bad distance type ",type(self.distance))
            return False
        if self.distance < 0 or self.distance > 100:
            print("bad distance value ",self.distance)
            return False
        if (self.level == 0) and (self.health > 0):
            print("zero level and non-zero health: illegal ")
            return False
        if (self.health == 0) and (self.level > 0):
            print("zero health and non-zero level: illegal ")
            return False

        return True

    def setLevel(self, level):
        # wire up debugging....
        if level > 8:
            return False
        if level < 0:
            return False
        self.level = level
        if level == 0:
            self.health = 0
            self.distance = 0
        return True

    def setHealth(self, health):
        if health > 100:
            return False
        if health < 0:
            return False
        self.health = health
        if health == 0:
            self.level = 0
            self.distance = 0
        return True        
        
    def addEnergy(self, concentrated=False):
        if self.level == 0:
            # special case - take it to level 1, 100%
            self.level = 1
            self.health = 100
        else:
            if concentrated:
                deltaEnergy = 10 * randint(15,25)
            else:
                deltaEnergy = 10 * randint(10,25)
            newEnergy = self.health * Resonator.level_energy[self.level] + deltaEnergy;
            newHealth = int(newEnergy/Resonator.level_energy[self.level])
            minHealth = min(self.health + 1, 100);
            if (newHealth > 100) :
                if self.level < 8:
                    self.setLevel(min(8, self.level+1))
                    self.setHealth(max(minHealth, newEnergy/Resonator.level_energy[self.level]))
                else:
                    self.setHealth(100)
            else:
                self.setHealth(newHealth);    
 
    def removeEnergy(self, concentrated=False):
        if (self.level == 0):
            return
        if concentrated:
            deltaEnergy = 10 * randint(25,45)
        else:   
            deltaEnergy = 10 * randint(10,25)
        newEnergy = self.health * Resonator.level_energy[self.level] - deltaEnergy
        newHealth = int(newEnergy/Resonator.level_energy[self.level])
        self.setHealth(max(0, newHealth));            

    def toLegacyStr(self):
        # print (" grabbing reso string: level ",self.level)
        return '{{"level": {0}, "health": {1}, "position": "{2}"}}'.format(self.level, self.health, self.position)

    # without the position, sometimes that is implied 
    def toBetterStr(self):
        if self.level == 0:
            return'"{0}": {{"level": {1} }}'.format(self.position, self.level)
        else:
            return '"{0}": {{"level": {1}, "health": {2}, "distance": {3} }}'.format(self.position, self.level, self.health, self.distance)

# WARNING! This class has multithreaded access.
# Before you access the data structure, grab the lock and release afterward
# do not do anything blocking under the lock
class Portal:

    valid_positions = [ "E", "NE", "N", "NW", "W", "SW", "S", "SE" ]
    valid_mods = ["FA","HS-C","HS-R","HS-VR","LA-R","LA-VR","SBUL","MH-C","MH-R","MH-VR","PS-C","PS-R","PS-VR","AXA","T"]
    reso_level_XM = [0.0, 1000.0, 1500.0, 2000.0, 2500.0, 3000.0, 4000.0, 5000.0, 6000.0 ]

    factionStr = ["neutral", "enlightenment", "resistance"]
    
    @staticmethod
    def getFactionId(faction):
        for idx in range(len(Portal.factionStr)):
            if Portal.factionStr[idx] == faction:
                print("Get faction id returns {0} for {1}".format(idx, faction))
                return idx
        return 0
    def __init__(self, id_, verbose):
        self.reset()
        self.id_ = id_
        self.title = "default portal"
        self.lock = threading.Lock()  
        self.create_time = time.time()
        self.verbose = verbose
        # print("Created a new portal object") 
        
    def reset(self):
        self.faction = 0
        self.health = 0
        self.level = 0
        self.owner = None
        self.resonators = {}
        self.links = []
        self.mods = []
     
        
    def addEnergy(self,faction, resonatorId):
        with self.lock:
            if self.faction == 0:
                self.faction = faction
                # clear out resonators, initalize fresh
                for position in Resonator.valid_positions:
                    reso = Resonator(position, {"level":1, "health":100, "owner":faction})
                    self.resonators[position]=reso
            else:
                if resonatorId:
                    self.resonators[resonatorId].addEnergy(concentrated=True)
                else:
                    for pos, resonator in self.resonators.items():
                        resonator.addEnergy();
    
    def removeEnergy(self, resonatorId):
        with self.lock:
            if resonatorId:
                self.resonators[resonatorId].removeEnergy(concentrated=True)
            else:
                for pos, resonator in self.resonators.items():
                    resonator.removeEnergy();

            regimeStable = False
            for pos, resonator in self.resonators.items():
                if (resonator.health > 0):
                    regimeStable = True
            
            if not regimeStable:
                self.faction = 0 # all resonators down. Back to neutral
    

    # returns a new object of the Portal type
    def dup(self):
        n = Portal(self.id_, self.verbose)
        n.faction = self.faction
        n.health = self.health
        n.level = self.level
        n.title = self.title
        n.owner = self.owner
        if self.resonators:
            n.resonators = self.resonators
        if self.links:
            n.links = self.links
        if self.mods:
            n.mods = self.mods
        n.lock = None
        n.create_time = self.create_time
        # print("Created a duplicate portal object")  
        return n

    # carefully avoid the lock and the creattime
    # otherwise we're copying the object into the self
    # no return
    def set(self, n):
        self.faction = n.faction
        self.health = n.health
        self.level = n.level
        self.title = n.title
        self.owner = n.owner
        self.resonators = n.resonators
        self.links = n.links
        self.mods = n.mods
        self.verbose = n.verbose

    # Health is calculated from resonators states so it is always correct
    def getLevel(self):
        if self.faction == 0:
            return 0
        if self.resonators == None:
            return 0
        if len(self.resonators) == 0:
            return 0
        level_sum = 0
        for k,v in self.resonators.items():
            level_sum += v.level
        return int (level_sum / 8)

    # health is in .... ???
    # Let's try average of the health of the resonators
    def getHealth(self):
        if self.faction == 0:
            return 0
        if self.resonators == None:
            return 0
        if len(self.resonators) == 0:
            return 0
        if self.faction == 0:
            return 0
        xm_max = 0.0
        xm = 0.0
        for k,v in self.resonators.items():
            reso_xm = self.reso_level_XM[v.level]
            xm_max += reso_xm
            xm += (float(v.health) / 100.0) * reso_xm
        if xm < 0.00001:
            return 0
        r = int ((xm / xm_max) * 100.0)
        if r > 100:
            r = 100
        return r


    # This function takes a Json object
    # Returns a float for how long to delay - when to read the next line
    def setStatus( self, jsonStr ):

        try:
            statusObj = json.loads(jsonStr)
        except Exception as ex:
            template = "Exception in Portal parsing the json string {0} occurred. Arguments:\n{1!r}"
            message = template.format(type(ex).__name__, ex.args)
            print( message )
            raise # pass it upstack


        if self.verbose:
            print ("+++++ object BEFORE changes: ",str(self))

        # print(" parsed JSON, taking lock. Object is: ",statusObj)
        with self.lock:
            portal = self.dup()

        if "title" in statusObj:
            if statusObj.get("title"):
                portal.title = str(statusObj.get("title"))

        if "faction" in statusObj:

            portal.faction = int(statusObj.get("faction"))

            # have to take off all resos
            if portal.faction == 0:
                portal.resonators = {}
                portal.mods = []
                portal.health = 0
                portal.level = 0
                portal.owner = None

        if "owner" in statusObj:
            portal.owner = str(statusObj.get("owner"))

        if "mods" in statusObj:
            portal.mods = list(statusObj.get("mods"))

        if "resonators" in statusObj:
            resonators = dict(statusObj.get("resonators"))
            for pos, values in resonators.items():
                r = Resonator(pos, values)
                portal.resonators[pos] = r

            # if we changed the resonators, update the health and level
            portal.level = portal.getLevel()
            portal.health = portal.getHealth()

        # validate the new object through the validator
        if portal.check() == False:
            raise ValueError('JSON Portal line is not consistant')

        else:

            # copy the parts that should be copied ( ie, not the lock or create time )
            with self.lock:
                self.set(portal)

        if self.verbose:
            print ("+++++ object after changes: ",str(self))

        # return value is the amount of delay to add
        if type(statusObj.get("delay", 0.0)) == float:
            delay = statusObj.get("delay", 0.0)

        return delay
        

    def getModsStr(self):
        if type(self.mods) != list:
            return '[]'
        if len(self.mods) == 0:
            return '[]'
        res = []
        res.append('[')
        for mod in self.mods:
            res.append('"')
            res.append(mod)
            res.append('"')
            res.append(',')
        res.pop()
        res.append(']')
        return ''.join(res)

    # This is the "current form" that is mussing a lot of information
    def statusLegacy(self):
        resos = []
        num_entries = 0
        for k, v in self.resonators.items():
            # skip if empty, saving space & time
            # print(" r position ",k," value ",str(v))
            if v.level == 0:
                continue
            num_entries += 1
            resos.append(v.toLegacyStr())
            resos.append(",")
        # have to take off the last comma if more than one item
        if num_entries > 0:
            resos.pop()
        reso_string = ''.join(resos)
        return '{{"controllingFaction": {0}, "health": {1}, "level": {2}, "title": "{3}", "resonators": [{4}]}}'.format( 
            self.faction, self.health, self.level, self.title, reso_string )

    # not legacy! The cool kid way with resonators as a dict
    def __str__(self):
        # shortcut - grey
        if (self.faction == 0):
            return '{{"faction": 0, "health": 0, "level": 0, "title":"{0}", "resonators": {{}}, "mods": [] }}'.format(self.title)

        #longcut
        howmany = 0
        resos = []
        #print ("we have {0} resonators".format(len(self.resonators)))
        for pos, resonator in self.resonators.items():
            # skip if empty, saving space & time
            if resonator.level == 0:
                continue
            howmany += 1
            resos.append(resonator.toBetterStr())
            resos.append(",")
        if (howmany > 0):
            resos.pop()
        reso_string = ''.join(resos)
        return '{{"faction": {0}, "health": {1}, "level": "{2}", "title": "{3}", "resonators": {{{4}}}, "mods": {5} }}'.format(
            self.faction, self.getHealth(), self.getLevel(), self.title, reso_string, self.getModsStr() )

    # this method makes sure the status is valid and reasonable ( no values greater than game state )
    def check(self):
        if type(self.faction) is not int:
            print("Portal faction type initvalid, is ",type(self.faction))
            return False
        if self.faction < 0 or self.faction > 2:
            print("Illegal Portal faction value ",self.faction)
            return False
        if type(self.health) is not int:
            print("Portal health type invalid, is ",type(self.health))
            return False
        if self.health < 0 or self.health > 100:
            print("Illegal Portal health value ",self.health)
            return False
        if type(self.level) is not int:
            print("Portal level type invalid, is ",type(self.level))
            return False
        if self.level < 0 or self.level > 8:
            print("Illegal Portal level value ",self.level)
            return False
        if type(self.title) is not str:
            print("Portal title type invalid, is ",type(self.title))
            return False
        if len(self.title) > 300:
            print("Portal title seems too long")
            return False
        if type(self.resonators) is not dict:
            print("Portal resonator type wrong, is ",type(self.resonators))
            return False
        if len(self.resonators) > 8:
            print("Portal has incorrect number of resonators ",len(self.resontaors))
            return False
        if (self.faction == 0) and (len(self.resonators) > 0):
            print(" portal must have faction if it has resonators ")
            return False
        for k,v in self.resonators.items():
            if k not in self.valid_positions:
                print("resonator has invalid position ",k)
                return False
            if v.check() == False:
                print(" resonator ",v," is not valid ")
                return False
        if type(self.mods) is not list:
            print("Mods wrong type, is ",type(self.mods))
            return False
        if len(self.mods) > 4:
            print("too many mods")
            return False
        if (self.faction == 0) and (len(self.mods) > 0):
            print(" portal must have faction if it has resonators ")
            return False
        for m in self.mods:
            if type(m) is not str:
                print (" type of one of the mods is wrong, is ",type(m))
                return False
            if m not in self.valid_mods:
                print ("invalid mod ",m)
                return False
        return True


#
# Background file processor
# 1. Open a file
# 2. Readline and set initial based on readline
# this is a co-routine. Post Python 3.5 the coroutine decorator turned into 'async'

async def fileReader(app):

    # file object
    f = None

    file_line = 0
    portal = app['portal']
    file_name = app['filename']
    verbose = app['verbose']

    while True:
#    for i in itertools.count():

        # if no file object open one
        if f == None:
            delay = 0.5
            try:
                f = open(file_name, 'r')
                file_line = 0
                # print (" opened file again ")
            except FileNotFoundError:
                # the most likely error
                print(" file ",file_name," was not found, trying again later")
            except Exception as ex:
                template = "An exception of type {0} occurred. Arguments:\n{1!r}"
                message = template.format(type(ex).__name__, ex.args)
                print( message )

        # if file object, read and jam it into the status object
        if f != None:
            delay = 0.0
            l = f.readline()
            file_line += 1
            # at end of file, close the file, allow a reopen
            if len(l) == 0:
                f.close()
                f = None
                print( " Reloading file ")
            else:
                l = l.rstrip()
                l = l.lstrip()
                if (type(l) == str and len(l) > 0):
                    if (l[0] == '#'):
                        # print("ignoring comment line")
                        pass
                    else:
                        try:
                            if verbose:
                                print("Portal set status: line ",file_line," using string: ",l)
                            delay = portal.setStatus(l)
                        except:
                            print("WARNING: could not process line ",file_line," ignoring ")
                            traceback.print_exc(file=sys.stdout)

        if type(delay) != float:
            delay = 0.0
        await asyncio.sleep(delay)

#
# A number of debug / demo endpoints
# Note to self: you create a "Response" object, thn
# you manipulate it.


async def statusFaction(request):
    portal = request.app['portal']
    faction = 0
    with portal.lock:
        faction = portal.faction
    return web.Response(text=str(faction))

async def statusHealth(request):
    portal = request.app['portal']
    health = 0
    with portal.lock:
        health = portal.health
    return web.Response(text=str(health))

# this needs UTF8 because names might have utf8
async def statusJson(request):
    portal = request.app['portal']
    portal_str = ""
    #print(" web request received ")
    with portal.lock:
        portal_str = str(portal)
    return web.Response(text=portal_str , charset='utf-8', headers={"Access-Control-Allow-Origin":"*"})

async def statusJsonLegacy(request):
    portal = request.app['portal']
    portal_str = ""
    with portal.lock:
        portal_str = portal.statusLegacy()
    return web.Response(text=portal_str , charset='utf-8')

# this rather pecular request does a delay, then responds, allowing you
# to test your polling code
async def delay(request):
    await asyncio.sleep(10.0)
    return web.Response(text="We delayed 10 seconds")

async def hello(request):
    return web.Response(text="Hello World!")
    
# And a couple of POST functions to handle attacks and defends
async def handleAttack(request):
    # get faction from request
    postData = await request.post()
    for key in postData:
        print ("postdata key " + key)
    if not "faction" in postData:
        print("No faction data, will not handle post!")
        return web.Response(status=400, headers={"Access-Control-Allow-Origin":"*"})

    faction = postData["faction"]
    if faction != "resistance" and faction != "enlightenment":
        print("Invalid faction data {}, will not handle post".format(faction))
        return web.Response(status=400, headers={"Access-Control-Allow-Origin":"*"})

    factionId = Portal.getFactionId(faction)
    portal = request.app['portal']
    if Portal.factionStr[portal.faction] == faction:
        print("Cannot attack own portal, will not handle post")
        return web.Response(status=400, headers={"Access-Control-Allow-Origin":"*"})
    
    if "resonator" in postData:
        resonatorId = postData["resonator"]
    else:
        resonatorId = None

    print("Removing Energy")    
    portal.removeEnergy(resonatorId)
        
    return web.Response(status=200, headers={"Access-Control-Allow-Origin":"*"})

async def handleDefend(request):
    # get faction from request
    postData = await request.post()
    if not "faction" in postData:
        print("No faction data, will not handle post!")
        return web.Response(status=400, headers={"Access-Control-Allow-Origin":"*"})

    faction = postData["faction"]
    if faction != "resistance" and faction != "enlightenment":
        print("Invalid faction {}, will not handle post".format(faction))
        return web.Response(status=400, headers={"Access-Control-Allow-Origin":"*"})

    factionId = Portal.getFactionId(faction)
    portal = request.app['portal']
    print("portal.faction is " + str(portal.faction))
    if Portal.factionStr[portal.faction] != "neutral" and Portal.factionStr[portal.faction] != faction:
        print("Cannot defend enemy's portal, will not handle post")
        return web.Response(status=400, headers={"Access-Control-Allow-Origin":"*"})
  
    if "resonator" in postData:
        resonatorId = postData["resonator"]
    else:
        resonatorId = None

    print("Adding energy!!") 
    portal.addEnergy(factionId,resonatorId)
    
    return web.Response(status=200, headers={"Access-Control-Allow-Origin":"*"})
         
async def handleReset(request):
    portal = request.app['portal']
    portal.reset()
    return web.Response(status=200, headers={"Access-Control-Allow-Origin":"*"})

# background tasks are covered near the bottom of this:
# http://aiohttp.readthedocs.io/en/stable/web.html
# Whatever tasks you create here will be executed and cancelled properly

async def start_background_tasks(app):
    app['file_task'] = app.loop.create_task( fileReader(app))


async def cleanup_background_tasks(app):
    app['file_task'].cancel()
    await app['file_task']

async def init(app, args):
    app = web.Application()

    app.router.add_get('/', hello)
    app.router.add_get('/delay', delay)

    app.router.add_get('/status/faction', statusFaction)
    app.router.add_get('/status/health', statusHealth)
    if args.legacy:
        app.router.add_get('/status/json', statusJsonLegacy)
    else:
        app.router.add_get('/status/json', statusJson)
        
    app.router.add_post('/attack', handleAttack)
    app.router.add_post('/defend', handleDefend)
    
    app.router.add_post('/reset', handleReset)

    # create the shared objects
    app['portal'] = Portal(1, args.verbose)
    app['relay'] = Relay()
    app['filename'] = args.filename
    app['verbose'] = args.verbose

    # background tasks are covered near the bottom of this:
    # http://aiohttp.readthedocs.io/en/stable/web.html
    #app.on_startup.append(start_background_tasks)
    #/app.on_cleanup.append(cleanup_background_tasks)

    return app

# Parse the command line options

parser = argparse.ArgumentParser(description="Ingress Portal TechThulu")
parser.add_argument('--port', '-p', help="HTTP port", default="5050", type=int)
parser.add_argument('--file', '-f', dest="filename", help="Simulator JSON file to process", default="tecthulu.json", type=str)
parser.add_argument('--legacy', help="Use legacy JSON format", action='store_true')
parser.set_defaults(legacy=False)
parser.add_argument('--verbose', '-v', help="Puts Lots of Printing Noise in", action='store_true')
parser.set_defaults(verbose=False)
args = parser.parse_args()

print("starting TechThulu simulator with file ",args.filename," on port ",args.port)

# register all the async stuff
loop = asyncio.get_event_loop()
app = loop.run_until_complete(init(web.Application(), args))

# run the web server
web.run_app(app, port=args.port)
