
from __future__ import absolute_import
from __future__ import division       # python 2/3 compatibility
from __future__ import print_function # python 2/3 compatibility

from s2clientprotocol import sc2api_pb2 as sc_pb

from six import iteritems # python 2/3 compatibility

import glob
import json
import os
import portpicker
import random
import re
import time

from sc2common    import types
from sc2gameLobby import dateFormat
from sc2gameLobby import gameConstants as c
from sc2gameLobby import ipAddresses
#import mapData
from sc2gameLobby import runConfigs
from sc2gameLobby import versions

from sc2players import getPlayer, buildPlayer, PlayerRecord, PlayerPreGame
from sc2players import constants as playerConstants
from sc2ladderMgmt.ladders import Ladder

"""
establish a configuration file containing game information such that other
processes can identify what any other established configuration already is
without necessitating inter-process communication for this information.
"""


################################################################################
def activeConfigs():
    """identify all configurations which are currently active"""
    #return [os.path.abspath(item) for item in os.listdir(c.FOLDER_ACTIVE_CONFIGS)]
    return glob.glob( os.path.join(c.FOLDER_ACTIVE_CONFIGS, "*.json") )


################################################################################
def clearConfigs():
    """remove all active configurations, if any"""
    for cfg in activeConfigs(): os.remove(cfg)


################################################################################
def loadHostConfig(timeout=15):
    start = time.time()
    while time.time() - start < timeout:
        for cfgName in activeConfigs():
            if re.search("^host", cfgName.split(os.sep)[-1]):
                cfg = Config()
                cfg.load(cfgName)
                return cfg
    raise c.TimeoutExceeded("could not identify the host config within %d seconds."%(timeout))


################################################################################
def getSlaveConfigs(numConfigs=1, timeout=15):
    ret = set()
    start = time.time()
    while len(ret) < numConfigs: # keep looking for slave configs until they're found or timeout
        if time.time() - start > timeout:
            raise c.TimeoutExceeded("could not identify any slave configs"\
                "within %d seconds"%(timeout))
        for cfgName in activeConfigs():
            if re.search("^host", cfgName): continue
            cfg = Config(host=True)
            cfg.load(cfgName)
            ret.add( cfg )
    return ret


################################################################################
class Config(object):
    """the grand collection of information that determines how a Starcraft 2
    game is setup and intended to behave"""
    ############################################################################
    def __init__(self, load=None,
        expo=c.DEFAULT_EXPANSION, version=None,
        ladder=None, players=[], whichPlayer="", mode=None, themap=None, numObservers=0, start=None, trust=True,
        ipAddress=[], ports=[], host=[], slaves=[],
        fogDisabled=False, stepSize=0, opponents=[], fullscreen=True,
        raw=False, score=False, feature=False, render=False,
        replay=None, debug=False,
            #connects=[],
            #state=c.GAME_INIT,
            #agentRaces=[], bots=[],
    ):
        self._gotPorts  = False # ensure that ports are only returned if they were retrieved on this machine
        # flexible settings auto-determination
        if isinstance(players, list):       pass
        elif hasattr(players, "__iter__"):  players = list(players)
        else:                               players = [players] # wrap
        # application setup info
        self.expo       = expo
        self.version    = version # automatically calculate unless specified
        # match setup info
        self.ladder     = ladder
        self.players    = players
        self.thePlayer  = whichPlayer
        self.mode       = mode
        self.themap     = themap
        self.numObserve = int(numObservers)
        self.start      = start  # indicates the final match configuration has been determined
        self.trustOpps  = trust  # request secure game hosting on an official ladder machine
        # connection info
        self.ipAddress  = ipAddress
        self.ports      = ports
        self.host       = host   # if defined, this config is for a slave
        self.slavePorts = slaves # if not defined, this is for the host
        self.ladderMsg  = ""
        # pre-game behavior
        self.scenario   = {}
        # in-game behavior
        self.fogDisabled= fogDisabled
        self.stepSize   = int(stepSize)
        self.opponents  = opponents # names of specific opponents
        self.fullscreen = fullscreen
        # observation data content
        self.raw        = raw
        self.score      = score
        self.feature    = feature
        self.render     = render
        # post-game behavior
        self.replay     = replay
        self.reqFiles   = []
        # class behavior
        self.debug      = debug
        #self.state = c.GAME_INIT
        if load:    self.load(load) # specific configuration filename to load
        else:       self.inflate()  # ensure all objects are objects
        self.isCustom   = bool(self.opponents or self.fogDisabled or \
                               self.themap    or self.stepSize)
        if self.version == None:
            self.version = self.getVersion()
    ############################################################################
    def __str__(self):  return self.__repr__()
    def __repr__(self):
        name = self.__class__.__name__
        if self.slavePorts: ip = [str(x) for x in self.host] # this configuration is a host configuration when slave ports are defined
        else:               ip = [str(x) for x in self.connection]
        ipStr = " @ %s"%(":".join(ip)) if ip else ""
        return "<%s %s %s%s>"%(name, self.installedApp.name(), self.version, ipStr)
    ############################################################################
    def __del__(self):
        self.returnPorts()
        try:    self.disable()
        except IOError: pass
    ############################################################################
    @property
    def attrs(self):
        data = dict(self.__dict__) # a new, altered dict is returned
        for k,v in sorted(iteritems(data)):
            #if re.search(Config.regexRaw, k): continue # ignore attrs that aren't raw data
            if re.search("^_", k):  del data[k]
        return data
    ############################################################################
    @property
    def agents(self):
        """identify which players are agents (not observers or computers). Errors if flattened."""
        ret = []
        for player in self.players:
            if player.isComputer: continue
            try:
                if player.observer: continue
            except: pass
            ret.append(player)
        return ret
    ############################################################################
    @property
    def agentRaces(self):
        """identify the races of the players that are agents. Errors if flattened."""
        return [player.selectedRace for player in self.players if not player.isComputer]
    ############################################################################
    @property
    def computers(self):
        """identify the players that are Blizzard's built-in bots. Errors if flattened."""
        return [player for player in self.players if player.isComputer]
    ############################################################################
    @property
    def allLobbySlots(self):
        """the current configuration of the lobby's players, defined before the match starts"""
        if self.debug:
            p      = ["Lobby Configuration detail:"] + \
                     ["    %s:%s%s"%(p, " "*(12-len(p.type)), p.name)]
                     #["    agent:     %s"%p for p in self.agents] + \
                     #["    computer:  %s, %s"%(r,d) for r,d in self.computers]
            if self.observers: # must separate condition because numObs is a number, not an iterator
                p += ["    observers: %d"%self.observers]
            print(os.linesep.join(p))
        return (self.agents, self.computers, self.observers)
    ############################################################################
    @property
    def connection(self):
        """identify the remote connection parameters"""
        self.getPorts()         # acquire if necessary
        self.getIPaddresses()   # acquire if necessary
        return (self.ipAddress, self.ports)
    ############################################################################
    @property
    def execPath(self):
        """the executable application's path"""
        vers = self.version.label if self.version else None # executables in Versions folder are stored by baseVersion (modified by game data patches)
        return self.installedApp.exec_path(vers)
    ############################################################################
    @property
    def is64bit(self):
        """whether the this machine is 64-bit capable or not"""
        return self.installedApp.is64bit
    ############################################################################
    @property
    def installedApp(self):
        """identify the propery application to launch, given the configuration"""
        try:    return self._installedApp
        except: # raises if not yet defined
            self._installedApp = runConfigs.get() # application/install/platform management
            return self._installedApp
    ############################################################################
    @property
    def interfaces(self):
        return (self.raw    , self.score,
                self.feature, self.render)
    ############################################################################
    @property
    def isHost(self):
        """is the host if it lacks host details"""
        return not bool(self.host) and bool(self.slavePorts)
    ############################################################################
    @property
    def isMultiplayer(self):
        return self.numGameClients > 1
    ############################################################################
    @property
    def mapData(self):
        """the raw, binary contents of the Starcraft2 map file"""
        return self.themap.rawData
        #with open(self.themap.path, "rb") as f:
        #    return f.read()
    ############################################################################
    @property
    def mapLocalPath(self):
        return self.themap.path
    ############################################################################
    @property
    def name(self):
        #try:    return self.cfgFile
        #except: pass # else extract name from loaded cfg name
        #k = "host" if self.host else os.getpid()
        #self.cfgFile = os.path.join(c.FOLDER_ACTIVE_CONFIGS, "%s_%s.json"%(k, dateFormat.now()))
        #return self.cfgFile
        return os.path.join(
                    c.FOLDER_ACTIVE_CONFIGS,
                    "%s_%s.json"%(os.getpid(), dateFormat.now(self.start)))
    ############################################################################
    @property
    def numAgents(self):
        return len(self.agents)
    ############################################################################
    @property
    def numBots(self):
        return len(self.computers)
    ############################################################################
    @property
    def numGameClients(self):
        """the number of game client connections in the match"""
        return self.numAgents + self.numObserve
    ############################################################################
    @property
    def numPlayers(self):
        #return self.numAgents + self.numBots
        return len(self.players)
    ############################################################################
    @property
    def observers(self):
        """the players who are actually observers"""
        ret = []
        for player in self.players:
            try:
                if player.observer: ret.append(player)
            except: pass # ignore PlayerRecords which don't have an observer attribute
        return ret
    ############################################################################
    @property
    def os(self):
        """the operating system this match is loaded on"""
        return self.installedApp.name()
    ############################################################################
    @property
    def participants(self):
        """agents + computers (i.e. all non-observers)"""
        ret = []
        for p in self.players:
            try:
                if     p.isComputer: ret.append(p)
                if not p.isObserver: ret.append(p) # could cause an exception if player isn't a PlayerPreGame
            except AttributeError: pass
        return ret
    ############################################################################
    @property
    def realtime(self):
        """whether the game progresses in realtime or requires each player to
        summit step requests to advance the game"""
        return not bool(self.stepSize)
    ############################################################################
    @property
    def teams(self):
        players = self.participants
        div     = len(players) / 2.
        teamSize= int(div)
        if (div - teamSize) > 0.49: # if odd number of participants
            teamSize += 1 # if remainder, round up
        return (players[:teamSize], players[teamSize:])
    ############################################################################
    def addPlayer(self, player):
        if not isinstance(player, PlayerPreGame):
            raise ValueError("%s must be a %s"%(player, PlayerPreGame))
        self.players.append(player)
    ############################################################################
    def clientInitHost(self):
        """extract ipAddress information used by the local client to connect to another SC2 client at this IP address"""
        if self.host:   return self.host[0]
        else:           return self.getIPaddresses()[-1]
    ############################################################################
    def clientInitPort(self):
        """extract the port that is used to connect to the locally launched SC2 client"""
        ports = self.getPorts()
        return ports[2] # private shared_port / init port
    ############################################################################
    def disable(self):
        try:
            os.remove(self.name) # delete the file
            #del self.cfgFile        # forget the cfg file's name
        except: pass
    ############################################################################
    def display(self, header=""):
        print(self)
        h = "[%s]  "%header.upper() if header else ""
        for k,v in sorted(iteritems(self.attrs)):
            print("%s%16s : %s"%(h,k,v))
    ############################################################################
    def flatten(self, data=None):
        """reduce all objects into simplified values as a attr dictionary that
        could be transformed back into a full configuration via inflate()"""
        if data == None: data=self.attrs
        ret = {}
        for k,v in iteritems(data):
            if not v: continue # don't flatten if there's nothing to flatten
            elif k == "expo":               v = v.type
            elif k == "version":            v = v.label
            elif k == "ladder":             v = v.name
            elif k == "players":
                newPs = []
                for i,p in enumerate(v):
                    try:    diff = p.difficulty.type
                    except: diff = p.difficulty
                    if isinstance(p, PlayerPreGame):    newPs.append( (p.name, p.type.type, p.initCmd, p.initOptions, diff, p.rating, p.selectedRace.type, self.numObserve, p.playerID, p.raceDefault) )
                    else:                               newPs.append( (p.name, p.type.type, p.initCmd, p.initOptions, diff, p.rating) )
                # TODO -- handle if type or observers params are not available (i.e. if a simple PlayerRecord, not a PlayerPreGame
                ret[k] = newPs
                continue
            elif k == "mode"   and self.mode:   v = v.type
            #elif k == "state":
            elif k == "themap" and self.themap: v = v.name
            ret[k] = v
        return ret
    ############################################################################
    def inflate(self, newData={}):
        """ensure all object attribute values are objects"""
        from sc2maptool.functions import selectMap
        from sc2maptool.mapRecord import MapRecord
        self.__dict__.update(newData)
        #if not isinstance(self.state, types.GameStates):      self.state     = types.GameStates(self.state)
        if self.expo    and not isinstance(self.expo, types.ExpansionNames):    self.expo       = types.ExpansionNames(self.expo)
        if self.version and not isinstance(self.version, versions.Version):     self.version    = versions.Version(self.version)
        if self.ladder  and not isinstance(self.ladder, Ladder):                self.ladder     = Ladder(self.ladder)
        for i,player in enumerate(self.players): # iterate over all players
            if       isinstance(player, str):                                   self.players[i] = getPlayer(player)
            elif not isinstance(player, PlayerRecord):                          self.players[i] = buildPlayer(*player)
        if self.mode    and not isinstance(self.mode, types.GameModes):         self.mode       = types.GameModes(self.mode)
        if self.themap  and not isinstance(self.themap, MapRecord):             self.themap     = selectMap(name=self.themap)
    ############################################################################
    def launchApp(self, **kwargs):
        """Launch Starcraft2 process in the background using this configuration.
        WARNING: if the same IP address and port are specified between multiple
                 SC2 process instances, all subsequent processes after the first
                 will fail to initialize and crash.
        """
        app = self.installedApp
        # TODO -- launch host in window minimized/headless mode
        vers = self.getVersion()
        return app.start(version=vers,#game_version=vers.baseVersion, data_version=vers.dataHash,
            full_screen=self.fullscreen, verbose=self.debug, **kwargs)
    ############################################################################
    def load(self, cfgFile=None, timeout=None):
        """expect that the data file has already been established"""
        #if cfgFile != None: self.cfgFile = cfgFile # if it's specified, use it
        if not cfgFile:
            cfgs = activeConfigs()
            if   len(cfgs) > 1: raise Exception("found too many configurations (%s); not clear which to load: %s"%(len(cfgs), cfgs))
            elif len(cfgs) < 1:
                if timeout: # wait for a configuration file to appear to be loaded
                    startWait = time.time()
                    timeReported = 0
                    while not cfgs:
                        timeWaited = time.time() - startWait
                        if timeWaited > timeout:
                            raise c.TimeoutExceeded("could not join game after %s seconds"%(timeout))
                        try:  cfgs = activeConfigs()
                        except:
                            if self.debug and timeWaited - timeReported >= 1:
                                timeReported += 1
                                print("second(s) waited for game to appear:  %d"%(timeReported))
                else:  raise Exception("must have a saved configuration to load or allow loading via timeout setting")
            cfgFile = cfgs.pop()
        try:
            with open(cfgFile, "rb") as f:
                data = f.read() # bytes => str
        except TypeError as e:
            print("ERROR %s: %s %s"%(e, cfgFile, type(cfgFile)))
            raise
        self.loadJson(data) # str => dict
        if self.debug:
            print("configuration loaded: %s"%(self.name))
            self.display()
    ############################################################################
    def loadJson(self, data):
        """convert the json data into updating this obj's attrs"""
        if not isinstance(data, dict):
            data = json.loads(data)
        self.__dict__.update(data)
        self.inflate() # restore objects from str values
        #if self.ports:  self._gotPorts = True
        return self
    ############################################################################
    def toJson(self, data=None, pretty=False):
        """convert the flattened dictionary into json"""
        if data==None: data = self.attrs
        data = self.flatten(data) # don't send objects as str in json
        #if pretty:
        ret = json.dumps(data, indent=4, sort_keys=True)
        #self.inflate() # restore objects from json str data
        return ret
    ############################################################################
    def getVersion(self):
        """the executable application's version"""
        if isinstance(self.version, versions.Version):  return self.version
        if self.version: # verify specified version exists
            version = versions.Version(self.version) # create this object to allow self._version_ to be specified in multiple different ways by the user
            if version.baseVersion not in self.installedApp.versionMap(): # verify that the selected version has an executable
                raise runConfigs.lib.SC2LaunchError(
                    "specified game version %s executable is not available.%s    available:  %s"%( \
                    version, os.linesep, "  ".join(self.installedApp.listVersions())))
            self.version = version
        else: # get most recent executable's version
            path = self.installedApp.exec_path()
            vResult = self.installedApp.mostRecentVersion
            self.version = versions.Version(vResult)
        if self.debug: print(os.linesep.join([
            "Game configuration detail:",
            "    platform:   %s"%(self.os),
            "    app:        %s"%(self.execPath),
            "    version:    %s"%(self.version)]))
        return self.version
    ############################################################################
    def getIPaddresses(self):
        """identify the IP addresses where this process client will launch the SC2 client"""
        if not self.ipAddress:
            self.ipAddress = ipAddresses.getAll() # update with IP address
        return self.ipAddress
    ############################################################################
    def getPorts(self):
        """acquire ports to be used by the SC2 client launched by this process"""
        if self.ports: # no need to get ports if ports are al
            return self.ports
        if not self._gotPorts:
            self.ports = [
                portpicker.pick_unused_port(), # game_port
                portpicker.pick_unused_port(), # base_port
                portpicker.pick_unused_port(), # shared_port / init port
            ]
            self._gotPorts = True
        return self.ports
    ############################################################################
    def requestCreateDetails(self):
        """add configuration to the SC2 protocol create request"""
        createReq = sc_pb.RequestCreateGame( # used to advance to Status.initGame state, when hosting
            realtime    = self.realtime,
            disable_fog = self.fogDisabled,
            random_seed = int(time.time()), # a game is created using the current second timestamp as the seed
            local_map   = sc_pb.LocalMap(map_path=self.mapLocalPath,
                                         map_data=self.mapData))
        for player in self.players:
            reqPlayer = createReq.player_setup.add() # add new player; get link to settings
            playerObj = PlayerPreGame(player)
            if playerObj.isComputer:
                reqPlayer.difficulty    = playerObj.difficulty.gameValue()
            reqPlayer.type              = c.types.PlayerControls(playerObj.control).gameValue()
            reqPlayer.race              = playerObj.selectedRace.gameValue()
        return createReq # SC2APIProtocol.RequestCreateGame
    ############################################################################
    def requestJoinDetails(self):
        """add configuration information to the SC2 protocol join request
    REQUIREMENTS FOR SUCCESSFUL LAUNCH:
    server game_port must match between all join requests to client (represents the host's port to sync game clients)
    server base_port
    client game_port must be unique between each client (represents ???)
    client base_port
    client shared_port must match between all join requests to client
        """
        raw,score,feature,rendered = self.interfaces
        interface = sc_pb.InterfaceOptions()
        interface.raw   = raw   # whether raw data is reported in observations
        interface.score = score # whether score data is reported in observations
        interface.feature_layer.width = 24
        #interface.feature_layer.resolution =
        #interface.feature_layer.minimap_resolution =
        joinReq = sc_pb.RequestJoinGame(
            options = interface,
            #observed_player_id=__?__,
            race = self.whoAmI().selectedRace.gameValue())
        # TODO -- allow player to be an observer, not just a player w/ race
        if self.host: # always add ports for joining player to connect to defined host
            hostPorts = self.host[1]
            joinReq.server_ports.game_port = hostPorts[0]
            joinReq.server_ports.base_port = hostPorts[1]
            joinReq.shared_port            = hostPorts[2]
            clientPorts = joinReq.client_ports.add()
            clientPorts.game_port = self.ports[0]
            clientPorts.base_port = self.ports[1]
            ret = self.ports[2]
        elif self.isMultiplayer: # always add ports as host of multiple agents/clients
            if len(self.ports) < 5:
                self.ports += [ # get new private client ports for the host
                    portpicker.pick_unused_port(), # game_port
                    portpicker.pick_unused_port(), # base_port
                ]
            joinReq.server_ports.game_port = self.ports[0]
            joinReq.server_ports.base_port = self.ports[1]
            joinReq.shared_port            = self.ports[2]
            clientPorts = joinReq.client_ports.add()
            clientPorts.game_port = self.ports[3] # new private client game port
            clientPorts.base_port = self.ports[4] # new private client base port
        return joinReq # SC2APIProtocol.RequestJoinGame
    ############################################################################
    def returnPorts(self):
        """deallocate specific ports on the current machine"""
        if self._gotPorts:
            #print("deleting ports >%s<"%(self.ports))
            map(portpicker.return_port, self.ports)
            self._gotPorts = False
        self.ports = []
    ############################################################################
    def save(self, filename=None, debug=False):
        """save a data file such that all processes know the game that is running"""
        if not filename: filename = self.name
        with open(filename, "w") as f: # save config data file
            f.write(self.toJson(self.attrs))
        if self.debug or debug:
            print("saved configuration %s"%(self.name))
            for k,v in sorted(iteritems(self.attrs)):
                print("%15s : %s"%(k,v))
    ############################################################################
    def updateIDs(self, ginfo, tag=None, debug=False):
        """ensure all player's playerIDs are correct given game's info"""
            # SC2APIProtocol.ResponseGameInfo attributes:
                # map_name
                # mod_names
                # local_map_path
                # player_info
                # start_raw
                # options
        thisPlayer = self.whoAmI()
        for pInfo in ginfo.player_info: # parse ResponseGameInfo.player_info to validate player information (SC2APIProtocol.PlayerInfo) against the specified configuration
            pID = pInfo.player_id
            if pID == thisPlayer.playerID: continue # already updated
            pCon = c.types.PlayerControls(pInfo.type)
            rReq = c.types.SelectRaces(pInfo.race_requested)
            for p in self.players: # ensure joined player is identified appropriately
                if p.playerID and p.playerID != pID: continue # if this non-matching player already has a set playerID, it can't match
                if p.control == pCon and p.selectedRace == rReq: # matched player
                    p.playerID = pID # updated player IDs should be saved into the game configuration
                    if debug: print("[%s] match contains %s."%(tag, p))
                    pID = 0 # declare that the player has been identified
                    break
            if pID: raise c.UnknownPlayer("could not match %s %s %s to any "
                "existing player of %s"%(pID, pCon, rReq, self.players))
    ############################################################################
    def whoAmI(self):
        """return the player object that owns this configuration"""
        self.inflate() # ensure self.players contains player objects
        if self.thePlayer:
            for p in self.players:
                if p.name != self.thePlayer: continue
                return p
        elif len(self.players) == 1:
            ret = self.players[0]
            self.thePlayer = ret.name # remember this for the future in case more players are added
            return ret
        raise Exception("could not identify which player this is given %s (%s)"%(self.players, self.thePlayer))

