"""
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 XYZExit, XYZRoom


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 is not None:
                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:
        print(
            "Warning: More than one XYZGrid instance 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   # debug
        if print_errors:
            print(err)
        else:
            xyzgrid.log(str(err))
    return xyzgrid
