"""Terrain generation for MUD worlds using deterministic noise."""

import math
import pathlib
import random


def _make_perm(seed: int) -> list[int]:
    """Generate permutation table from seed."""
    rng = random.Random(seed)
    p = list(range(256))
    rng.shuffle(p)
    return p + p


def _noise(perm: list[int], x: float, y: float, px: int, py: int) -> float:
    """2D tileable Perlin noise.

    Wraps seamlessly with period px along x and py along y.
    """
    # unit grid cell
    fx = math.floor(x)
    fy = math.floor(y)
    xi = int(fx) % px
    yi = int(fy) % py
    xi1 = (xi + 1) % px
    yi1 = (yi + 1) % py

    # relative coordinates within cell
    xf = x - fx
    yf = y - fy

    # fade curves (inlined)
    u = xf * xf * xf * (xf * (xf * 6 - 15) + 10)
    v = yf * yf * yf * (yf * (yf * 6 - 15) + 10)

    # hash coordinates of 4 corners (wrapping)
    aa = perm[perm[xi] + yi]
    ab = perm[perm[xi] + yi1]
    ba = perm[perm[xi1] + yi]
    bb = perm[perm[xi1] + yi1]

    # grad + lerp inlined for all 4 corners
    xf1 = xf - 1
    yf1 = yf - 1

    def _g(h: int, gx: float, gy: float) -> float:
        h &= 7
        a = gx if h < 4 else gy
        b = gy if h < 4 else gx
        return (a if (h & 1) == 0 else -a) + (b if (h & 2) == 0 else -b)

    g_aa = _g(aa, xf, yf)
    g_ba = _g(ba, xf1, yf)
    g_ab = _g(ab, xf, yf1)
    g_bb = _g(bb, xf1, yf1)

    x1 = g_aa + u * (g_ba - g_aa)
    x2 = g_ab + u * (g_bb - g_ab)
    return x1 + v * (x2 - x1)


class World:
    """Procedurally generated terrain world."""

    TERRAIN_CHARS = {
        "mountain": "^",
        "forest": "T",
        "grass": ".",
        "sand": ":",
        "water": "~",
    }

    def __init__(
        self,
        seed: int = 42,
        width: int = 1000,
        height: int = 1000,
        cache_dir: pathlib.Path | None = None,
    ):
        self.seed = seed
        self.width = width
        self.height = height
        self.cached = False

        # try loading from cache
        if cache_dir is not None:
            terrain = self._load_cache(cache_dir)
            if terrain is not None:
                self.terrain = terrain
                self.cached = True
                return

        # generate terrain grid
        self.terrain = self._generate_terrain()

        # save to cache for next time
        if cache_dir is not None:
            self._save_cache(cache_dir)

    def _generate_elevation(self) -> list[list[float]]:
        """Generate tileable elevation map using fractal noise.

        Computes at 1/4 resolution and interpolates up for speed.
        Tiles seamlessly in both axes so the world can wrap.
        """
        w = self.width
        h = self.height
        perm = _make_perm(self.seed)
        noise = _noise

        # generate at 1/4 resolution (no boundary padding, we wrap instead)
        step = 4
        cw = w // step
        ch = h // step

        coarse = [[0.0] * cw for _ in range(ch)]
        min_val = float("inf")
        max_val = float("-inf")

        for cy in range(ch):
            row = coarse[cy]
            for cx in range(cw):
                # map coarse coords back to world space [0, 1)
                wx = cx * step / w
                wy = cy * step / h

                # each octave tiles at its own frequency
                value = (
                    noise(perm, wx * 4, wy * 4, 4, 4)
                    + noise(perm, wx * 8, wy * 8, 8, 8) * 0.5
                    + noise(perm, wx * 16, wy * 16, 16, 16) * 0.25
                )
                row[cx] = value
                if value < min_val:
                    min_val = value
                if value > max_val:
                    max_val = value

        # normalize coarse grid to [0, 1]
        val_range = max_val - min_val
        if val_range > 0:
            inv_range = 1.0 / val_range
            for cy in range(ch):
                row = coarse[cy]
                for cx in range(cw):
                    row[cx] = (row[cx] - min_val) * inv_range

        # bilinear interpolation to full resolution (wrapping at edges)
        inv_step = 1.0 / step
        elevation = [[0.0] * w for _ in range(h)]

        for y in range(h):
            cy_f = y * inv_step
            cy_floor = int(cy_f)
            cy0 = cy_floor % ch
            cy1 = (cy0 + 1) % ch
            fy = cy_f - cy_floor
            fy_inv = 1.0 - fy
            row_0 = coarse[cy0]
            row_1 = coarse[cy1]
            out_row = elevation[y]

            for x in range(w):
                cx_f = x * inv_step
                cx_floor = int(cx_f)
                cx0 = cx_floor % cw
                cx1 = (cx0 + 1) % cw
                fx = cx_f - cx_floor

                # bilinear interpolation
                out_row[x] = (
                    row_0[cx0] * (1.0 - fx) * fy_inv
                    + row_0[cx1] * fx * fy_inv
                    + row_1[cx0] * (1.0 - fx) * fy
                    + row_1[cx1] * fx * fy
                )

        return elevation

    def _derive_terrain_from_elevation(
        self, elevation: list[list[float]]
    ) -> list[list[str]]:
        """Convert elevation map to terrain types."""
        w = self.width
        h = self.height
        # local lookups
        mountain = self.TERRAIN_CHARS["mountain"]
        forest = self.TERRAIN_CHARS["forest"]
        grass = self.TERRAIN_CHARS["grass"]
        sand = self.TERRAIN_CHARS["sand"]
        water = self.TERRAIN_CHARS["water"]

        terrain = [[""] * w for _ in range(h)]

        for y in range(h):
            elev_row = elevation[y]
            terr_row = terrain[y]
            for x in range(w):
                e = elev_row[x]
                if e > 0.75:
                    terr_row[x] = mountain
                elif e > 0.55:
                    terr_row[x] = forest
                elif e > 0.25:
                    terr_row[x] = grass
                elif e > 0.15:
                    terr_row[x] = sand
                else:
                    terr_row[x] = water

        return terrain

    def _generate_rivers(
        self, terrain: list[list[str]], elevation: list[list[float]]
    ) -> None:
        """Generate rivers from mountains to water bodies."""
        rng = random.Random(self.seed)
        w = self.width
        h = self.height

        # find some high-elevation starting points
        num_rivers = max(5, (w * h) // 50000)

        for _ in range(num_rivers):
            # pick random high elevation point
            x = rng.randint(0, w - 1)
            y = rng.randint(0, h - 1)

            if elevation[y][x] < 0.6:
                continue

            # trace downhill (wrapping at edges)
            visited = set()
            while True:
                if (x, y) in visited or len(visited) > 200:
                    break
                visited.add((x, y))

                # if we hit water, stop
                if terrain[y][x] == self.TERRAIN_CHARS["water"]:
                    break

                # carve river
                if elevation[y][x] < 0.75:  # don't carve through mountains
                    terrain[y][x] = self.TERRAIN_CHARS["water"]

                # find steepest descent to neighbor (wrapping)
                current_elevation = elevation[y][x]
                best_x, best_y = x, y
                best_elevation = current_elevation

                for dx, dy in [(-1, 0), (1, 0), (0, -1), (0, 1)]:
                    nx = (x + dx) % w
                    ny = (y + dy) % h
                    if elevation[ny][nx] < best_elevation:
                        best_elevation = elevation[ny][nx]
                        best_x, best_y = nx, ny

                if (best_x, best_y) == (x, y):
                    break

                x, y = best_x, best_y

    def _generate_terrain(self) -> list[list[str]]:
        """Generate complete terrain map."""
        elevation = self._generate_elevation()
        terrain = self._derive_terrain_from_elevation(elevation)
        self._generate_rivers(terrain, elevation)
        return terrain

    def _cache_path(self, cache_dir: pathlib.Path) -> pathlib.Path:
        return cache_dir / f"terrain_{self.seed}_{self.width}x{self.height}.bin"

    def _load_cache(self, cache_dir: pathlib.Path) -> list[list[str]] | None:
        """Load terrain from a cached binary file. Returns None on miss."""
        path = self._cache_path(cache_dir)
        if not path.exists():
            return None
        data = path.read_bytes()
        if len(data) != self.width * self.height:
            return None
        terrain = []
        for y in range(self.height):
            offset = y * self.width
            row = [chr(b) for b in data[offset : offset + self.width]]
            terrain.append(row)
        return terrain

    def _save_cache(self, cache_dir: pathlib.Path) -> None:
        """Save terrain to a binary file (1 byte per tile)."""
        cache_dir.mkdir(parents=True, exist_ok=True)
        data = bytearray()
        for row in self.terrain:
            data.extend(ord(c) for c in row)
        self._cache_path(cache_dir).write_bytes(bytes(data))

    def wrap(self, x: int, y: int) -> tuple[int, int]:
        """Wrap coordinates to stay within world bounds."""
        return x % self.width, y % self.height

    def get_tile(self, x: int, y: int) -> str:
        """Return terrain character at position (wrapping)."""
        wx, wy = self.wrap(x, y)
        return self.terrain[wy][wx]

    def is_passable(self, x: int, y: int) -> bool:
        """Check if position is passable (wrapping)."""
        tile = self.get_tile(x, y)
        return tile not in {self.TERRAIN_CHARS["mountain"], self.TERRAIN_CHARS["water"]}

    def get_viewport(
        self, cx: int, cy: int, width: int, height: int
    ) -> list[list[str]]:
        """Return 2D slice of terrain centered on (cx, cy), wrapping at edges."""
        viewport = []

        half_width = width // 2
        half_height = height // 2

        start_y = cy - half_height
        start_x = cx - half_width

        for dy in range(height):
            row = []
            for dx in range(width):
                wx, wy = self.wrap(start_x + dx, start_y + dy)
                row.append(self.terrain[wy][wx])
            viewport.append(row)

        return viewport
