
import copy
from ..utils import log
from ..graph.graph_utils import GraphUtils, vanillaTransitions, vanillaBossesTransitions, escapeSource, escapeTargets, graphAreas, getAccessPoint
from ..logic.logic import Logic
from ..graph.graph import AccessGraphRando as AccessGraph
from ..logic.smbool import SMBool
from ..utils.objectives import Objectives
from ..rando.ItemLocContainer import getItemLocStr
from collections import defaultdict

# creates graph and handles randomized escape
class GraphBuilder(object):
    def __init__(self, graphSettings, random):
        self.graphSettings = graphSettings
        self.areaRando = graphSettings.areaRando
        self.bossRando = graphSettings.bossRando
        self.escapeRando = graphSettings.escapeRando
        self.minimizerN = graphSettings.minimizerN
        self.log = log.get('GraphBuilder')
        self.random = random

    # builds everything but escape transitions
    def createGraph(self, maxDiff):
        transitions = self.graphSettings.plandoRandoTransitions
        if transitions is None:
            transitions = []
            if self.minimizerN is not None:
                forcedAreas = set()
                # if no Crateria and auto escape trigger, we connect door connected to G4 to climb instead (see below).
                # This wouldn't work here, as Tourian is isolated in the resulting seed (see below again)
                # (well we could do two different transitions on both sides of doors, but that would just be confusing)
                # so we force crateria to be in the graph
                if self.graphSettings.startAP == "Golden Four" and self.graphSettings.tourian == "Disabled":
                    forcedAreas.add('Crateria')
                # force areas required by objectives
                # 1st the 'clear area' ones
                forcedAreas = forcedAreas.union({goal.area for goal in Objectives.objDict[self.graphSettings.player].activeGoals if goal.area is not None})
                # for the rest, base ourselves on escapeAccessPoints :
                # - if only "1 of n" pick an area, preferably one already forced
                # - filter out G4 AP (always there)
                for goal in Objectives.objDict[self.graphSettings.player].activeGoals:
                    if goal.area is None:
                        n, apNames = goal.escapeAccessPoints
                        aps = [getAccessPoint(apName) for apName in apNames]
                        if len(aps) >= n:
                            n -= len([ap for ap in aps if ap.Boss])
                            escAreas = {ap.GraphArea for ap in aps if not ap.Boss}
                            objForced = forcedAreas.intersection(escAreas)
                            escAreasList = sorted(list(escAreas))
                            while len(objForced) < n and len(escAreasList) > 0:
                                objForced.add(escAreasList.pop(self.random.randint(0, len(escAreasList)-1)))
                            forcedAreas = forcedAreas.union(objForced)
                transitions = GraphUtils.createMinimizerTransitions(self.graphSettings.startAP, self.minimizerN, sorted(list(forcedAreas)), random=self.random)
            else:
                if not self.bossRando:
                    transitions += vanillaBossesTransitions
                else:
                    transitions += GraphUtils.createBossesTransitions(self.random)
                if not self.areaRando:
                    transitions += vanillaTransitions
                else:
                    transitions += GraphUtils.createAreaTransitions(self.graphSettings.lightAreaRando, random=self.random)
        ret = AccessGraph(Logic.accessPoints, transitions, self.graphSettings.dotFile)
        Objectives.objDict[self.graphSettings.player].setGraph(ret, maxDiff)
        return ret
    
    def addForeignItems(self, container, itemLocs):
        itemPoolCounts = {}
        for item in container.itemPool:
            if item.Code is not None:
                itemPoolCounts[item.Type] = itemPoolCounts.get(item.Type, 0) + 1
        itemLocsCounts = {}
        for il in itemLocs:
            if il.Item.Code is not None and il.player == container.sm.player:
                itemLocsCounts[il.Item.Type] = itemLocsCounts.get(il.Item.Type, 0) + 1

        for item, count in itemPoolCounts.items():
            for n in range(max(0, count - itemLocsCounts.get(item, 0))):
                container.sm.addItem(item)

    # fills in escape transitions if escape rando is enabled
    # escapeTrigger = None or (itemLocs, progItemlocs) couple from filler
    def escapeGraph(self, container, graph, maxDiff, escapeTrigger):
        if not self.escapeRando:
            return True
        emptyContainer = copy.copy(container)
        emptyContainer.resetCollected(reassignItemLocs=True)
        dst = None
        if escapeTrigger is None:
            possibleTargets, dst, path = self.getPossibleEscapeTargets(emptyContainer, graph, maxDiff)
            # update graph with escape transition
            graph.addTransition(escapeSource, dst)
            paths = [path]
        else:
            self.addForeignItems(emptyContainer, escapeTrigger[0])
            possibleTargets, paths = self.escapeTrigger(emptyContainer, graph, maxDiff, escapeTrigger)
            if paths is None:
                return False
        # get timer value
        self.escapeTimer(graph, paths, self.areaRando or escapeTrigger is not None)
        self.log.debug("escapeGraph: ({}, {}) timer: {}".format(escapeSource, dst, graph.EscapeAttributes['Timer']))
        # animals
        GraphUtils.escapeAnimalsTransitions(graph, possibleTargets, dst, self.random)
        return True

    def _getTargets(self, sm, graph, maxDiff):
        possibleTargets = [target for target in escapeTargets if graph.accessPath(sm, target, 'Landing Site', maxDiff) is not None]
        self.log.debug('_getTargets. targets='+str(possibleTargets))
        # failsafe
        if len(possibleTargets) == 0:
            self.log.debug("Can't randomize escape, fallback to vanilla")
            possibleTargets.append('Climb Bottom Left')
        self.random.shuffle(possibleTargets)
        return possibleTargets

    def getPossibleEscapeTargets(self, emptyContainer, graph, maxDiff):
        sm = emptyContainer.sm
        # setup smbm with item pool:
        # - Ice not usable because of hyper beam
        # - remove energy to avoid hell runs
        # - (will add bosses as well)
        sm.addItems([item.Type for item in emptyContainer.itemPool if item.Type != 'Ice' and item.Category != 'Energy'])
        sm.addItem('Hyper')
        possibleTargets = self._getTargets(sm, graph, maxDiff)
        # pick one
        dst = possibleTargets.pop()
        path = graph.accessPath(sm, dst, 'Landing Site', maxDiff)
        return (possibleTargets, dst, path)

    def escapeTrigger(self, emptyContainer, graph, maxDiff, escapeTrigger):
        container = emptyContainer
        sm = container.sm
        allItemLocs,progItemLocs,split = escapeTrigger[0],escapeTrigger[1],escapeTrigger[2]
        # check if crateria is connected, if not replace Tourian
        # connection with Climb and add special escape patch to Climb
        if not any(il.Location.GraphArea == "Crateria" for il in allItemLocs):
            escapeAttr = graph.EscapeAttributes
            if "patches" not in escapeAttr:
                escapeAttr['patches'] = []
            escapeAttr['patches'] += ['climb_disable_bomb_blocks.ips', "Climb_Asleep"]
            src, _ = next(t for t in graph.InterAreaTransitions if t[1].Name == "Golden Four")
            graph.removeTransitions("Golden Four")
            graph.addTransition(src.Name, "Climb Bottom Left")
            # disconnect the other side of G4
            graph.addTransition("Golden Four", "Golden Four")
        # remove vanilla escape transition
        graph.addTransition('Tourian Escape Room 4 Top Right', 'Tourian Escape Room 4 Top Right')
        # filter garbage itemLocs
        ilCheck = lambda il: not il.Location.isBoss() and not il.Location.restricted and il.Item.Category != "Nothing"
        # update item% objectives
        accessibleItems = [il.Item for il in allItemLocs if ilCheck(il)]
        majorUpgrades = [item.Type for item in accessibleItems if item.BeamBits != 0 or item.ItemBits != 0]
        if split == "Scavenger":
            # update escape access for scav with last scav loc
            lastScavItemLoc = progItemLocs[-1]
            sm.objectives.updateScavengerEscapeAccess(lastScavItemLoc.Location.accessPoint)
            sm.objectives.setScavengerHuntFunc(lambda sm, ap: sm.haveItem(lastScavItemLoc.Item.Type))
        else:
            # update "collect all items in areas" funcs
            availLocsByArea=defaultdict(list)
            for itemLoc in allItemLocs:
                if ilCheck(itemLoc) and (split.startswith("Full") or itemLoc.Location.isClass(split)):
                    availLocsByArea[itemLoc.Location.GraphArea].append(itemLoc.Location.Name)
            self.log.debug("escapeTrigger. availLocsByArea="+str(availLocsByArea))
            sm.objectives.setItemPercentFuncs(len(accessibleItems), majorUpgrades, container)
            sm.objectives.setAreaFuncs({area:lambda sm,ap:SMBool(len(container.getLocs(lambda loc: loc.Name in availLocsByArea[area]))==0) for area in availLocsByArea})
        self.log.debug("escapeTrigger. collect locs until G4 access")
        # collect all item/locations up until we can pass G4 (the escape triggers)
        itemLocs = allItemLocs[:]
        ap = "Landing Site" # dummy value it'll be overwritten at first collection
        while len(itemLocs) > 0 and not (sm.canPassG4() and graph.canAccess(sm, ap, "Landing Site", maxDiff)):
            il = itemLocs.pop(0)
            # can happen with item links replacement items that its not in the container's itemPool
            if il.Location.restricted or il.Item.Type == "ArchipelagoItem" or il.Item not in container.itemPool:
                continue
            self.log.debug("collecting " + getItemLocStr(il))
            container.collect(il)
            ap = il.Location.accessPoint
        # final update of item% obj
        collectedLocsAccessPoints = {il.Location.accessPoint for il in container.itemLocations}
        sm.objectives.updateItemPercentEscapeAccess(list(collectedLocsAccessPoints))
        possibleTargets = self._getTargets(sm, graph, maxDiff)
        # try to escape from all the possible objectives APs
        possiblePaths = []
        for goal in Objectives.objDict[self.graphSettings.player].activeGoals:
            n, possibleAccessPoints = goal.escapeAccessPoints
            count = 0
            for ap in possibleAccessPoints:
                self.log.debug("escapeTrigger. testing AP " + ap)
                path = graph.accessPath(sm, ap, 'Landing Site', maxDiff)
                if path is not None:
                    self.log.debug("escapeTrigger. add path from "+ap)
                    possiblePaths.append(path)
                    count += 1
            if count < n:
                # there is a goal we cannot escape from
                self.log.debug("escapeTrigger. goal %s: found %d/%d possible escapes, abort" % (goal.name, count, n))
                return (None, None)
        # try and get a path from all possible areas
        self.log.debug("escapeTrigger. completing paths")
        allAreas = {il.Location.GraphArea for il in allItemLocs if not il.Location.restricted and not il.Location.GraphArea in ["Tourian", "Ceres"]}
        def getStartArea(path):
            return path[0].GraphArea
        def apCheck(ap):
            nonlocal graph, possiblePaths
            apObj = graph.accessPoints[ap]
            return apObj.GraphArea not in [getStartArea(path) for path in possiblePaths]
        escapeAPs = [ap for ap in collectedLocsAccessPoints if apCheck(ap)]
        for ap in escapeAPs:
            path = graph.accessPath(sm, ap, 'Landing Site', maxDiff)
            if path is not None:
                self.log.debug("escapeTrigger. add path from "+ap)
                possiblePaths.append(path)
        def areaPathCheck():
            nonlocal allAreas, possiblePaths
            startAreas = {getStartArea(path) for path in possiblePaths}
            return len(allAreas - startAreas) == 0
        while not areaPathCheck() and len(itemLocs) > 0:
            il = itemLocs.pop(0)
            if il.Location.restricted or il.Item.Type == "ArchipelagoItem":
                continue
            self.log.debug("collecting " + getItemLocStr(il))
            container.collect(il)
            ap = il.Location.accessPoint
            if apCheck(ap):
                path = graph.accessPath(sm, ap, 'Landing Site', maxDiff)
                if path is not None:
                    self.log.debug("escapeTrigger. add path from "+ap)
                    possiblePaths.append(path)

        return (possibleTargets, possiblePaths)

    def _computeTimer(self, graph, path):
        traversedAreas = list(set([ap.GraphArea for ap in path]))
        self.log.debug("escapeTimer path: " + str([ap.Name for ap in path]))
        self.log.debug("escapeTimer traversedAreas: " + str(traversedAreas))
        # rough estimates of navigation within areas to reach "borders"
        # (can obviously be completely off wrt to actual path, but on the generous side)
        traversals = {
            'Crateria':90,
            'GreenPinkBrinstar':90,
            'WreckedShip':120,
            'LowerNorfair':135,
            'WestMaridia':75,
            'EastMaridia':100,
            'RedBrinstar':75,
            'Norfair': 120,
            'Kraid': 40,
            'Crocomire': 40,
            # can't be on the path
            'Tourian': 0,
        }
        t = 90 if self.areaRando else 0
        for area in traversedAreas:
            t += traversals[area]
        t = max(t, 180)
        return t


    # path: as returned by AccessGraph.accessPath
    def escapeTimer(self, graph, paths, compute):
        if len(paths) == 1:
            path = paths.pop()
            if compute == True:
                if path[0].Name == 'Climb Bottom Left':
                    graph.EscapeAttributes['Timer'] = None
                    return
                t = self._computeTimer(graph, path)
            else:
                escapeTargetsTimer = {
                    'Climb Bottom Left': None, # vanilla
                    'Green Brinstar Main Shaft Top Left': 210, # brinstar
                    'Basement Left': 210, # wrecked ship
                    'Business Center Mid Left': 270, # norfair
                    'Crab Hole Bottom Right': 270 # maridia
                }
                t = escapeTargetsTimer[path[0].Name]
            self.log.debug("escapeTimer. t="+str(t))
            graph.EscapeAttributes['Timer'] = t
        else:
            assert compute
            graph.EscapeAttributes['Timer'] = 0
            timerValues = {}
            graph.EscapeAttributes['TimerTable'] = timerValues
            for path in paths:
                area = path[0].GraphArea
                prev = timerValues.get(area, 0)
                t = max(prev, self._computeTimer(graph, path))
                timerValues[area] = t
                self.log.debug("escapeTimer. area=%s, t=%d" % (area, t))
            for area in graphAreas[1:-1]:  # no Ceres or Tourian
                if area not in timerValues:
                    # area not in graph most probably, still write a 10 minute "ultra failsafe" value
                    timerValues[area] = 600
