import importlib
import logging
import random
import tomllib
from collections import defaultdict
from dataclasses import dataclass, field
from functools import cached_property
from pathlib import Path
from types import SimpleNamespace
from typing import Dict, List

from PIL import Image, ImageDraw

from tilemapper.grid import Grid, Position

DEFAULT_TILE_SIZE_IN_PIXELS = 128


class UnsupportedTileException(Exception):
    pass


class MissingImageDataException(Exception):
    pass


class MisconfiguredTileSetException(Exception):
    pass


TileSetConfig = SimpleNamespace


@dataclass(kw_only=True)
class Tile:
    """
    A base class repesenting a single member of a TileSet.  Only supports
    text rendering; other tile types should subclass this class.
    """

    name: str
    char: str
    config: TileSetConfig

    def render(self):
        return str(self)

    @cached_property
    def terrain_name(self):
        return self.name.split("_", 1)[0]

    def __str__(self):
        return self.char


@dataclass
class TileSet:
    """
    Base class representing all tiles in a set which can be used to render
    battle maps. Only supports text rendering; other set types should subclass
    this class.
    """

    config: TileSetConfig
    tiles: Dict[str, Tile] = field(default_factory=lambda: defaultdict(list))

    tile_type = Tile

    def load(self):
        self.tiles = defaultdict(list)
        for char, name in self.config.legend.items():
            self.add(self.tile_type(char=char, name=name, config=self.config))

    def add(self, tile: Tile):
        """
        Add a Tile to the set.
        """
        self.tiles[tile.name].append(tile)

    def get(self, char: str) -> Tile:
        """
        Return the Tile instance corrresponding to an input character.
        """
        if char not in self.config.legend:
            raise UnsupportedTileException(f"'{char}' is not supported by the current tile set.")
        name = self.config.legend[char]
        return random.choice(self.tiles[name]) if name in self.tiles else self.placeholder

    def render_grid(self, grid: Grid, width: int = 0, height: int = 0) -> str:
        output = ""
        for y in range(0, height):
            for x in range(0, width):
                try:
                    pos = grid.at(y, x)
                except AttributeError:
                    continue
                if pos and pos.value:
                    output += pos.value.render()
            output += "\n"
        return output.rstrip("\n")

    @property
    def empty_space(self) -> Tile:
        """
        Return the Tile instance representing empty space.
        """
        return self.get(" ")

    def __str__(self):
        return f"[Tileset] {self.config.tileset['name']}: {self.config.tileset['desc']}"


@dataclass
class ColorizedTile(Tile):
    """
    A variant of the base Tile type that supports colorized console output
    using ANSI color codes.
    """

    def render(self):
        color = self.config.color_map[self.char]
        return f"[{color}]{self.char}[/{color}]"

    def __str__(self):
        return self.render()


class ColorizedTileSet(TileSet):
    """
    A variant of the base TileSet type that uses ColorizedTiles.
    """

    tiles: Dict[str, ColorizedTile] = {}

    tile_type = ColorizedTile


@dataclass
class ImageTile(Tile):
    """
    A Tile subclass that uses PNG images.

    A single Tile must have one or more images in its paths.  When an ImageTile
    is rendered, a random path from ImageTile.paths will be selected.
    """

    paths: List[Path] = field(default_factory=list)
    buffer: Image = None

    @property
    def image(self) -> Image:
        """
        Select a random image file from ImageTile.paths and return it as an
        Image instace. If the buffer contains image data, return that instead.
        """
        if self.buffer:
            return self.buffer
        return Image.open(random.choice(self.paths))

    @property
    def width(self) -> int:
        return self.image.size[0]

    @property
    def height(self) -> int:
        return self.image.size[1]

    def render(
        self,
        size: int = 0,
        nw: Tile = None,
        n: Tile = None,
        ne: Tile = None,
        e: Tile = None,
        se: Tile = None,
        s: Tile = None,
        sw: Tile = None,
        w: Tile = None,
    ) -> Image:
        """
        Render the image as a square SIZE pixels on a side.

        If any of the directional parameters are defined, their images will be
        pasted into the existing image as squares SIZE/4 pixels on a side, at
        locations suitable for creating a 32-pixel border around the image.
        """
        if not size:
            size = self.image.width
        rendered = self.image.copy().resize((size, size))
        border_size = int(size / 4)

        if nw:
            rendered.paste(nw.image, (0, 0), nw.image)
        if n:
            rendered.paste(n.image, (border_size, 0), n.image)
            rendered.paste(n.image, (border_size * 2, 0), n.image)
        if ne:
            rendered.paste(ne.image, (border_size * 3, 0), ne.image)
        if e:
            rendered.paste(e.image, (border_size * 3, border_size), e.image)
            rendered.paste(e.image, (border_size * 3, border_size * 2), e.image)
        if se:
            rendered.paste(se.image, (border_size * 3, border_size * 3), se.image)
        if s:
            rendered.paste(s.image, (border_size, border_size * 3), s.image)
            rendered.paste(s.image, (border_size * 2, border_size * 3), s.image)
        if sw:
            rendered.paste(sw.image, (0, border_size * 3), sw.image)
        if w:
            rendered.paste(w.image, (0, border_size), w.image)
            rendered.paste(w.image, (0, border_size * 2), w.image)

        return rendered


@dataclass
class ImageTileSet(TileSet):
    """
    A set of image tiles. By default, image tiles are expected to be 128x128
    pixels and will be cropped to match. This can be controlled by specifying
    tile_size at instantiation.

    FILENAMES

    Images loaded for the tile set must use filenames following the pattern:

        TERRAIN_VARIANT.EXT.

    where TERRAIN is the name of the terrain, matching the TileSet.config.legend
    values, and VARIANT is an integer. EXT is the filename extension; images
    can be any format supported by the Pillow library on your system.

    EDGES AND CORNERS

    Besides terrain images, ImageTileSet also supports edge and corner images
    that can be used to draw the transition between one terrain type those
    adjacent to it. For example, to draw a shoreline between water terrain and
    grass, you might create the following images:

        water_edge_grass_n_1.png
        water_edge_grass_e_1.png
        water_edge_grass_s_1.png
        water_edge_grass_w_1.png
        water_edge_grass_ne_1.png
        water_edge_grass_nw_1.png
        water_edge_grass_se_1.png
        water_edge_grass_sw_1.png
        water_corner_ne_1.png
        water_corner_nw_1.png
        water_corner_se_1.png
        water_corner_sw_1.png

    A Tile with terrain_name "water" which is bordered by a "ground" Tile to the
    north will have the 'water_edge_grass_n' tile pasted onto it at the following
    positions on the water tile image, assuming a 128x128 tile and 32x32 edge tiles:

        0,0   0,32    0,64    0,96

    If the water Tile is also bordered by grass to the west and east, the
    following overlays will be created:

        OVERLAY               POSITION
        water_edge_grass_nw   0,0
        water_edge_grass_n    0,32
        water_edge_grass_n    0,64
        water_edge_grass_ne   0,96

    And so on, for all cardinal directions.

    If a water Tile is bordered by water on the north and the west, but the
    Tile to the northwest is grass, the 'water_corner_nw_1.png' tile will
    be pasted onto it at position (0,0); so too the ne, se, and sw tiles at
    their respsective positions.

    Like base tiles, edge and corner tiles can have multiple variants and
    will be chosen at random when rendered.
    """

    _tile_cache = {}  # ImageTile data
    _image_cache = {}  # rendered image data

    @cached_property
    def paths(self) -> Dict[str, List[Path]]:
        paths = defaultdict(list)
        for imgfile in sorted(self.config.path.glob("*.png")):
            (terrain_name, *parts) = imgfile.stem.rsplit("_")
            key = terrain_name
            if parts[0] in ("edge", "corner"):
                key = "_".join([terrain_name, *parts[:-1]])
            paths[key].append(imgfile)
        return paths

    @cached_property
    def placeholder(self):
        buffer = Image.new("RGB", (self.tile_size, self.tile_size))
        ImageDraw.Draw(buffer).text((3, 3), "?", (255, 255, 255))
        return buffer

    def load(self):
        """
        Walk the config.legend and load the images associated with each terrain type.
        """
        self.tiles = defaultdict(list)
        for char, name in self.config.legend.items():
            if name not in self.paths:
                raise MissingImageDataException(
                    f"The tile set does not contain any images for the '{char}' ({name}) terrain."
                )
            for path in self.paths[name]:
                key = f"{name}-{path.name}"
                if key not in self._tile_cache:
                    tile = ImageTile(char=char, name=name, buffer=Image.open(path), config=self.config)
                    self._tile_cache[key] = tile
                    self.add(tile)
        for name in self.paths:
            if name not in self.config.legend.values():
                logging.warn(f"{name} images exist but do not map to terrain types in the legend.")

    def _get_overlays(self, position: Position, adjacent: List[Position] = []) -> tuple:
        """
        Inspect the grid positions adjacent to the specified position, and
        return a tuple of edge and corner tile names that should be applied.
        """
        terrain = position.value.terrain_name
        (nw_terrain, n_terrain, ne_terrain, e_terrain, se_terrain, s_terrain, sw_terrain, w_terrain) = [
            a.value.terrain_name for a in adjacent
        ]
        n = f"{terrain}_edge_{n_terrain}_n" if n_terrain != terrain else None
        s = f"{terrain}_edge_{s_terrain}_s" if s_terrain != terrain else None
        e = f"{terrain}_edge_{e_terrain}_e" if e_terrain != terrain else None
        w = f"{terrain}_edge_{w_terrain}_w" if w_terrain != terrain else None
        nw = f"{n}w" if n_terrain != terrain and w_terrain == n_terrain else n
        nw = f"{terrain}_corner_nw" if n_terrain == terrain and nw_terrain != terrain else nw or w
        sw = f"{s}w" if s_terrain != terrain and w_terrain == s_terrain else s
        sw = f"{terrain}_corner_sw" if s_terrain == terrain and sw_terrain != terrain else sw or w
        ne = f"{n}e" if n_terrain != terrain and e_terrain == n_terrain else n
        ne = f"{terrain}_corner_ne" if n_terrain == terrain and ne_terrain != terrain else ne or e
        se = f"{s}e" if s_terrain != terrain and e_terrain == s_terrain else s
        se = f"{terrain}_corner_se" if s_terrain == terrain and se_terrain != terrain else se or e

        return (nw, n, ne, e, se, s, sw, w)

    def render_grid(self, grid: Grid, width: int, height: int) -> Image:
        map_image = Image.new("RGBA", (self.config.tileset["size"] * width, self.config.tileset["size"] * height))
        for y in range(0, height):
            for x in range(0, width):
                pos = grid.at(y, x)
                if not pos or pos.value == self.empty_space:
                    continue
                map_image.paste(
                    self.render_tile(pos, [a or pos for a in grid.adjacent(pos)]),
                    (self.config.tileset["size"] * x, self.config.tileset["size"] * y),
                )
        return map_image

    def render_tile(self, position, adjacent) -> Image:
        """
        Return a rendered image of the tile in the specified position, including any edge and
        corner overlays implied by the tiles adjacent to it.
        """
        key = ":".join([str(position), *[str(a) for a in adjacent]])
        if key not in self._image_cache:
            self._image_cache[key] = position.value.render(
                self.config.tileset["size"],
                *[self._image_cache.get(overlay) for overlay in self._get_overlays(position, adjacent)],
            )
        return self._image_cache[key]


class TileSetManager:
    """
    Helper class for managing multiple tile sets in the specified path. The configuration directory is
    expected to contain subdirectories, each with a tileset.toml file. ImageTileSets should also include
    one or more tiles for each defined terrain type:

    config_dir/
        set1/
            tileset.toml
            terrain_1.png
            OtherTerrain_1.png
            ...
        set2/
            tileset.toml
            ...

    """

    DEFAULT_TILE_SET = ImageTileSet

    def __init__(self, config_dir: Path = Path(__file__).parent.parent / "tilesets"):
        self.config_dir = config_dir

    @cached_property
    def available(self):
        """
        Parse the tileset.toml file of every tile set in the configuration directory.
        """
        available = {}
        for config_file in self.config_dir.rglob("tileset.toml"):
            config = tomllib.loads(config_file.read_bytes().decode())
            available[config_file.parent.name] = TileSetConfig(path=config_file.parent, **config)
        return available

    def load(self, name: str) -> TileSet:
        """
        Load the specified tile set, which it is assumed should be an ImageTileSet.
        """
        tileset_class = self.DEFAULT_TILE_SET
        custom_class = self.available[name].tileset.get("class")
        if custom_class:
            try:
                module, class_name = custom_class.rsplit(".", 1)
                tileset_class = getattr(importlib.import_module(module), class_name)
            except ImportError as e:
                raise MisconfiguredTileSetException(
                    f"{self.config.path}: Could not import custom class {custom_class}: {e}"
                )
        tileset = tileset_class(self.available[name])
        tileset.load()
        return tileset
