import random
import tomllib
from dataclasses import dataclass, field
from functools import cached_property
from pathlib import Path
from typing import Dict, List

from PIL import Image, ImageDraw

from tilemapper.grid import Position

DEFAULT_TILE_SIZE_IN_PIXELS = 128

COLOR_MAP = {
    ".": "white on grey58",
    "d": "bold cyan on grey58",
    "D": "bold green on grey58",
    "L": "bold dark_red on grey58",
    "S": "bold black on grey58",
    "v": "bold steel_blue on grey3",
    "^": "bold steel_blue on grey3",
    "0": "white on dark_red",
    "1": "white on dark_red",
    "2": "white on dark_red",
    "3": "white on dark_red",
    "4": "white on dark_red",
    "5": "white on dark_red",
    "6": "white on dark_red",
    "7": "white on dark_red",
    "8": "white on dark_red",
    "9": "white on dark_red",
    "_": "white on steel_blue",
    ",": "black on dark_green",
}


class MissingTileException(Exception):
    pass


@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

    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(kw_only=True)
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.
    """
    name: str
    desc: str = ""
    tiles: Dict[str, Tile] = field(default_factory=dict)

    # The default character map is suitable for dungeons and other structures.
    character_map: Dict[str, str] = field(
        default_factory=lambda: {
            " ": "empty",
            ".": "ground",
            ",": "grass",
            "_": "water",
            "d": "door, open",
            "D": "door, closed",
            "L": "door, locked",
            "S": "door, secret",
            "v": "stairs, down",
            "^": "stairs, up",
            "0": "location 0",
            "1": "location 1",
            "2": "location 2",
            "3": "location 3",
            "4": "location 4",
            "5": "location 5",
            "6": "location 6",
            "7": "location 7",
            "8": "location 8",
            "9": "location 9",
        }
    )

    def add(self, tiles: List[Tile]):
        """
        Add a list of Tiles to the set.
        """
        for tile in tiles:
            self.tiles[tile.name] = tile

    def get(self, char: str) -> Tile:
        """
        Return the Tile instance corrresponding to an input character.
        """
        t = self.character_map[char]
        try:
            return self.tiles[f"{t}"]
        except KeyError:
            raise MissingTileException(f'"{self}" does not contain a "{t}" tile.')

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

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


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

    def __str__(self):
        return f"[{self.color}]{self.char}[/{self.color}]"


class ColorizedTileSet(TileSet):
    """
    A variant of the base TileSet type that uses ColorizedTiles.
    """
    tiles: Dict[str, 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]
    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.character_map
    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.
    """
    image_dir: Path
    tile_size: int = 128

    _cache = {}

    def _load_images(self, char: str, name: str):
        """
        Load the images in the tile set corresponding to a terrain type in the
        character map, and organize them into ImageTiles, potentially with
        variants.  Loaded image data is added to the ImageTileSet._cache; each
        ImageTile will point at the cached data.
        """
        paths = []
        lastkey = None
        key = None
        for imgfile in sorted(self.image_dir.glob(f"{name}_*.*")):
            (terrain_name, *parts) = imgfile.stem.rsplit("_")
            key = terrain_name
            if parts[0] in ("edge", "corner"):
                key = "_".join([terrain_name, *parts[:-1]])
            if not lastkey:
                lastkey = key
            if lastkey != key:
                self.add([self._get_or_create_tile(char=char, name=lastkey, paths=paths)])
                lastkey = key
                paths = []
            paths.append(imgfile)
        if key:
            self.add([self._get_or_create_tile(char=char, name=key, paths=paths)])

    def load(self):
        """
        Walk the characeter_map and load the images associated with it each
        terrain type.
        """
        self.tiles = {}
        for char, name in self.character_map.items():
            self._load_images(char, name)

    def _get_or_create_tile(self, char: str, name: str, paths: List[Path]) -> ImageTile:
        """
        Load image data for the specified terrain type. If the expected image
        file cannot be found, generated a placeholder image. The resulting image
        data is cached for reuse, making this method idempotent.
        """
        if name in self._cache:
            return self._cache[name]
        buffer = None
        if not paths:
            buffer = Image.new("RGBA", (self.tile_size, self.tile_size))
            ImageDraw.Draw(buffer).text((3, 3), name, (255, 255, 255))
        self._cache[name] = ImageTile(char=char, name=name, paths=paths, buffer=buffer)
        return self._cache[name]

    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_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.
        """
        return position.value.render(
            self.tile_size, *[self._cache.get(overlay) for overlay in self._get_overlays(position, adjacent)]
        )


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
            ...

    """
    def __init__(self, config_dir: Path):
        self.config_dir = config_dir
        self._available = {}
        self._find_tilesets()

    @property
    def available(self) -> dict:
        """
        Return a mapping of available tile sets by name.
        """
        return {"console": self.console_map, "colorized": self.console_map_colorized, **self._available}

    def _find_tilesets(self):
        """
        Parse the tileset.toml file of every tile set in the configuration directory.
        """
        self._available = {}
        for config_file in self.config_dir.rglob("tileset.toml"):
            config = tomllib.loads(config_file.read_bytes().decode())
            config["config_dir"] = config_file.parent
            self._available[config["config_dir"].name] = config

    def load(self, name: str) -> ImageTileSet:
        """
        Load the specified tile set, which it is assumed should be an ImageTileSet.
        """
        config = self.available[name]
        tileset = ImageTileSet(
            image_dir=config["config_dir"],
            name=config["tileset"]["name"],
            desc=config["tileset"]["desc"],
            tile_size=config["tileset"].get("size", DEFAULT_TILE_SIZE_IN_PIXELS),
            character_map=config["legend"],
        )
        tileset.load()
        return tileset

    @cached_property
    def console_map(self) -> TileSet:
        ts = TileSet(name="console", desc="Tiles used for input and text rendering.")
        ts.add([Tile(char=key, name=value) for key, value in ts.character_map.items()])
        return ts

    @cached_property
    def console_map_colorized(self) -> ColorizedTileSet:
        ts = ColorizedTileSet(name="colorized", desc="Colorized ASCII.")
        ts.add([
                ColorizedTile(char=key, name=value, color=COLOR_MAP.get(key, "grey"))
                for key, value in ts.character_map.items()
        ])
        return ts
