# Source code for evennia.contrib.grid.xyzgrid.xyzgrid

    """
    The grid

    This represents the full XYZ grid, which consists of

    - 2D `Map`-objects parsed from Map strings and Map-legend components. Each represents one
      Z-coordinate or location.
    - `Prototypes` for how to build each XYZ component into 'real' rooms and exits.
    - Actual in-game rooms and exits, mapped to the game based on Map data.

    The grid has three main functions:
    - Building new rooms/exits from scratch based on one or more Maps.
    - Updating the rooms/exits tied to an existing Map when the Map string
      of that map changes.
    - Fascilitate communication between the in-game entities and their Map.

    """
    from evennia.scripts.scripts import DefaultScript
    from evennia.utils import logger
    from evennia.utils.utils import variable_from_module
    from .xymap import XYMap
    from .xyzroom import XYZRoom, XYZExit

    class XYZGrid(DefaultScript):
        """
        Main grid class. This organizes the Maps based on their name/Z-coordinate.

        """

        def at_script_creation(self):
            """
            What we store persistently is data used to create each map (the legends, names etc)

            """
            self.db.map_data = {}
            self.desc = "Manages maps for XYZ-grid"

        @property
        def grid(self):
            if self.ndb.grid is None:
                self.reload()
            return self.ndb.grid

        def get_map(self, zcoord):
            """
            Get a specific xymap.

            Args:
                zcoord (str): The name/zcoord of the xymap.

            Returns:
                XYMap: Or None if no map was found.

            """
            return self.grid.get(zcoord)

        def all_maps(self):
            """
            Get all xymaps stored in the grid.

            Returns:
                list: All initialized xymaps stored with this grid.

            """
            return list(self.grid.values())

        def log(self, msg):
            logger.log_info(f"|grid| {msg}")

        def get_room(self, xyz, **kwargs):
            """
            Get one or more room objects from XYZ coordinate.

            Args:
                xyz (tuple): X,Y,Z coordinate of room to fetch. '*' acts
                as wild cards.

            Returns:
                Queryset: A queryset of XYZRoom(s) found.

            Raises:
                XYZRoom.DoesNotExist: If room is not found.

            Notes:
                This assumes the room was previously built.

            """
            return XYZRoom.objects.filter_xyz(xyz=xyz, **kwargs)

        def get_exit(self, xyz, name="north", **kwargs):
            """
            Get one or more exit object at coordinate.

            Args:
                xyz (tuple): X,Y,Z coordinate of the room the
                    exit leads out of. '*' acts as a wildcard.
                name (str): The full name of the exit, e.g. 'north' or 'northwest'.
                    The '*' acts as a wild card.

            Returns:
                Queryset: A queryset of XYZExit(s) found.

            """
            kwargs["db_key"] = name
            return XYZExit.objects.filter_xyz_exit(xyz=xyz, **kwargs)

        def maps_from_module(self, module_path):
            """
            Load map data from module. The loader will look for a dict XYMAP_DATA or a list of
            XYMAP_DATA_LIST (a list of XYMAP_DATA dicts). Each XYMAP_DATA dict should contain
            `{"xymap": mapstring, "zcoord": mapname/zcoord, "legend": dict, "prototypes": dict}`.

            Args:
                module_path (module_path): A python-path to a module containing
                    map data as either `XYMAP_DATA` or `XYMAP_DATA_LIST` variables.

            Returns:
                list: List of zero, one or more xy-map data dicts loaded from the module.

            """
            map_data_list = variable_from_module(module_path, "XYMAP_DATA_LIST")
            if not map_data_list:
                map_data_list = [variable_from_module(module_path, "XYMAP_DATA")]
            # inject the python path in the map data
            for mapdata in map_data_list:
                if not mapdata:
                    self.log(f"Could not find or load map from {module_path}.")
                    return
                mapdata["module_path"] = module_path
            return map_data_list

        def reload(self):
            """
            Reload and rebuild the grid. This is done on a server reload.

            """
            self.log("(Re)loading grid ...")
            self.ndb.grid = {}
            nmaps = 0
            loaded_mapdata = {}
            changed = []
            mapdata = self.db.map_data

            if not mapdata:
                self.db.mapdata = mapdata = {}

            # generate all Maps - this will also initialize their components
            # and bake any pathfinding paths (or load from disk-cache)
            for zcoord, old_mapdata in mapdata.items():

                self.log(f"Loading map '{zcoord}'...")

                # we reload the map from module
                new_mapdata = loaded_mapdata.get(zcoord)
                if not new_mapdata:
                    if "module_path" in old_mapdata:
                        for mapdata in self.maps_from_module(old_mapdata["module_path"]):
                            loaded_mapdata[mapdata["zcoord"]] = mapdata
                    else:
                        # nowhere to reload from - use what we have
                        loaded_mapdata[zcoord] = old_mapdata

                    new_mapdata = loaded_mapdata.get(zcoord)

                if new_mapdata != old_mapdata:
                    self.log(f" XYMap data for Z='{zcoord}' has changed.")
                    changed.append(zcoord)

                xymap = XYMap(dict(new_mapdata), Z=zcoord, xyzgrid=self)
                xymap.parse()
                xymap.calculate_path_matrix()
                self.ndb.grid[zcoord] = xymap
                nmaps += 1

            # re-store changed data
            for zcoord in changed:
                self.db.map_data[zcoord] = loaded_mapdata[zcoord]

            # store
            self.log(f"Loaded and linked {nmaps} map(s).")
            self.ndb.loaded = True

        def add_maps(self, *mapdatas):
            """
            Add map or maps to the grid.

            Args:
                *mapdatas (dict): Each argument is a dict structure
                    `{"map": <mapstr>, "legend": <legenddict>, "name": <name>,
                    "prototypes": <dict-of-dicts>, "module_path": <str>}`. The `prototypes are
                    coordinate-specific overrides for nodes/links on the map, keyed with their
                    (X,Y) coordinate within that map. The `module_path` is injected automatically
                    by self.maps_from_module.

            Raises:
                RuntimeError: If mapdata is malformed.

            """
            for mapdata in mapdatas:
                zcoord = mapdata.get("zcoord")
                if not zcoord:
                    raise RuntimeError("XYZGrid.add_map data must contain 'zcoord'.")

                self.db.map_data[zcoord] = mapdata

        def remove_map(self, *zcoords, remove_objects=True):
            """
            Remove an XYmap from the grid.

            Args:
                *zoords (str): The zcoords/XYmaps to remove.
                remove_objects (bool, optional): If the synced database objects (rooms/exits) should
                    be removed alongside this map.
            """
            # from evennia import set_trace;set_trace()
            for zcoord in zcoords:
                if zcoord in self.db.map_data:
                    self.db.map_data.pop(zcoord)
                if remove_objects:
                    # we can't batch-delete because we want to run the .delete
                    # method that also wipes exits and moves content to save locations
                    for xyzroom in XYZRoom.objects.filter_xyz(xyz=("*", "*", zcoord)):
                        xyzroom.delete()
            self.reload()

        def delete(self):
            """
            Clear the entire grid, including database entities, then the grid too.

            """
            mapdata = self.db.map_data
            if mapdata:
                self.remove_map(*(zcoord for zcoord in self.db.map_data), remove_objects=True)
            super().delete()

        def spawn(self, xyz=("*", "*", "*"), directions=None):
            """
            Create/recreate/update the in-game grid based on the stored Maps or for a specific Map
            or coordinate.

            Args:
                xyz (tuple, optional): An (X,Y,Z) coordinate, where Z is the name of the map. `'*'`
                    acts as a wildcard.
                directions (list, optional): A list of cardinal directions ('n', 'ne' etc).
                    Spawn exits only the given direction. If unset, all needed directions are spawned.

            Examples:
                - `xyz=('*', '*', '*')` (default) - spawn/update all maps.
                - `xyz=(1, 3, 'foo')` - sync a specific element of map 'foo' only.
                - `xyz=('*', '*', 'foo') - sync all elements of map 'foo'
                - `xyz=(1, 3, '*') - sync all (1,3) coordinates on all maps (rarely useful)
                - `xyz=(1, 3, 'foo')`, `direction='ne'` - sync only the north-eastern exit
                    out of the specific node on map 'foo'.

            """
            x, y, z = xyz
            wildcard = "*"

            if z == wildcard:
                xymaps = self.grid
            elif self.ndb.grid and z in self.ndb.grid:
                xymaps = {z: self.grid[z]}
            else:
                raise RuntimeError(f"The 'z' coordinate/name '{z}' is not found on the grid.")

            # first build all nodes/rooms
            for zcoord, xymap in xymaps.items():
                self.log(f"spawning/updating nodes for Z='{zcoord}' ...")
                xymap.spawn_nodes(xy=(x, y))

            # next build all links between nodes (including between maps)
            for zcoord, xymap in xymaps.items():
                self.log(f"spawning/updating links for Z='{zcoord}' ...")
                xymap.spawn_links(xy=(x, y), directions=directions)

    def get_xyzgrid(print_errors=True):
        """
        Helper for getting the grid. This will create the XYZGrid global script if it didn't
        previously exist.

        Args:
            print_errors (bool, optional): Print errors directly to console rather than to log.

        """
        xyzgrid = XYZGrid.objects.all()
        if not xyzgrid:
            # create a new one
            xyzgrid, err = XYZGrid.create("XYZGrid")
            if err:
                raise RuntimeError(err)
            xyzgrid.reload()
            return xyzgrid
        elif len(xyzgrid) > 1:
            (
                "Warning: More than one XYZGrid instances were found. This is an error and "
                "only the first one will be used. Delete the other one(s) manually."
            )
        xyzgrid = xyzgrid[0]
        try:
            if not xyzgrid.ndb.loaded:
                xyzgrid.reload()
        except Exception as err:
            raise
            if print_errors:
                print(err)
            else:
                xyzgrid.log(str(err))
        return xyzgrid