"""
STARWASTE - World Generation
Procedural world generation for post-apocalyptic planets
"""

import numpy as np
from pyfastnoiselite.pyfastnoiselite import FastNoiseLite, NoiseType
import random
import math
import asyncio
import threading
from concurrent.futures import ThreadPoolExecutor
from collections import OrderedDict
from typing import Tuple, Dict, List, Set, Optional, TYPE_CHECKING
from shared.constants import TILE_SIZE, CHUNK_SIZE, BIOMES
from shared.utils import SeededRandom, console

if TYPE_CHECKING:
    from server.entities import EntityManager
    from server.database import Database


class Chunk:
    """World chunk containing tiles"""
    
    def __init__(self, chunk_x: int, chunk_y: int):
        self.chunk_x = chunk_x
        self.chunk_y = chunk_y
        self.tiles: List[List[int]] = []
        self.tile_corners: List[List[Tuple[int, int, int, int]]] = []  # (NW, NE, SW, SE) terrain types
        self.entities: List = []

        # Persistence tracking: chunks are effectively immutable today, so
        # we only persist them once (on first generation) unless future code
        # starts mutating tiles.
        self._persist_rev: int = 0
        self._persist_saved_rev: int = 0

    def mark_dirty(self):
        self._persist_rev += 1

    def mark_clean_if_unchanged(self, rev: int) -> bool:
        if self._persist_rev == rev:
            self._persist_saved_rev = rev
            return True
        return False
        
    def get_tile(self, local_x: int, local_y: int) -> int:
        """Get tile at local chunk coordinates"""
        if 0 <= local_y < len(self.tiles) and 0 <= local_x < len(self.tiles[0]):
            return self.tiles[local_y][local_x]
        return 0
    
    def get_corners(self, local_x: int, local_y: int) -> Optional[Tuple[int, int, int, int]]:
        """Get corner types at local chunk coordinates"""
        if 0 <= local_y < len(self.tile_corners) and 0 <= local_x < len(self.tile_corners[0]):
            return self.tile_corners[local_y][local_x]
        return None


class WorldGenerator:
    """Procedural world generator"""
    
    # Biome-based terrain layers (matching create_test_map.py)
    # These correspond to wang tile terrain layers in biome configs
    TERRAIN_WATER = 0      # Deep/shallow water (low elevation)
    TERRAIN_WASTELAND = 1  # Default terrain (medium elevation/moisture)
    TERRAIN_DESERT = 2     # Desert sand (high elevation + dry)
    
    def __init__(self, seed: int):
        """
        Initialize world generator
        
        Args:
            seed: World seed for procedural generation
        """
        self.seed = seed
        self.rng = SeededRandom(seed)
        
        # Randomly select biome
        import random
        random.seed(seed)
        available_biomes = ["toxic_wasteland", "frozen_world"]
        self.biome_id = random.choice(available_biomes)
        console.print(f"🌍 Selected biome: {self.biome_id}")
        
        # 🔥 FastNoiseLite noise generators (C++ backend, 50-100× faster!)
        # Using multiple generators with different seeds for variety
        self.elevation_noise = FastNoiseLite(seed=seed)        # Terrain height
        self.elevation_noise.noise_type = NoiseType.NoiseType_OpenSimplex2
        
        self.moisture_noise = FastNoiseLite(seed=seed + 1000)  # Wetness/dryness
        self.moisture_noise.noise_type = NoiseType.NoiseType_OpenSimplex2
        
        self.temperature_noise = FastNoiseLite(seed=seed + 2000)  # Hot/cold
        self.temperature_noise.noise_type = NoiseType.NoiseType_OpenSimplex2
        
        self.detail_noise = FastNoiseLite(seed=seed + 3000)    # Small details
        self.detail_noise.noise_type = NoiseType.NoiseType_OpenSimplex2
        
        # Legacy noise generators (for special features)
        self.radiation_noise = FastNoiseLite(seed=seed + 4000)
        self.radiation_noise.noise_type = NoiseType.NoiseType_OpenSimplex2
        
        self.ruins_noise = FastNoiseLite(seed=seed + 5000)
        self.ruins_noise.noise_type = NoiseType.NoiseType_OpenSimplex2
        
        # Noise parameters (high frequency = small, scattered features)
        self.height_scale = 1.2   # 🔥 HIGH frequency = small terrain patches
        self.moisture_scale = 1.4  # 🔥 HIGH frequency = scattered biome variation
        self.octaves = 2  # 🔥 More octaves = smooth, detailed transitions
        self.persistence = 0.5  # Standard persistence
        
        # 🔥 LRU CACHE: Prevent RAM explosion when generating infinite terrain!
        self.MAX_CHUNK_CACHE = 200  # Server can cache more (no rendering overhead)
        self.MAX_TILE_CACHE = 20000  # Server handles more concurrent chunks
        self.chunk_cache = OrderedDict()  # (chunk_x, chunk_y) -> Chunk
        self.tile_cache = OrderedDict()   # (world_x, world_y) -> terrain_type
        
        console.print(f"🌍 WorldGenerator initialized (seed: {seed})")
        console.print(f"   Using FastNoiseLite (C++ optimized) with NumPy vectorization")
        console.print(f"   Biome system: Elevation + Moisture + Temperature")
    
    def generate_chunk(self, chunk_x: int, chunk_y: int) -> Chunk:
        """
        Generate a chunk of the world
        
        Args:
            chunk_x: Chunk X coordinate
            chunk_y: Chunk Y coordinate
        
        Returns:
            Generated Chunk
        
        🔥 LRU CACHED + VECTORIZED: 50-100× faster with NumPy batch processing!
        """
        chunk_key = (chunk_x, chunk_y)
        
        # Check cache first - move to end (mark as recently used)
        if chunk_key in self.chunk_cache:
            self.chunk_cache.move_to_end(chunk_key)
            return self.chunk_cache[chunk_key]
        
        chunk = Chunk(chunk_x, chunk_y)
        
        # 🔥 VECTORIZED: Generate ALL tiles at once with NumPy!
        # Create coordinate grids for all vertices (65×65 for 64×64 tiles)
        vertex_size = CHUNK_SIZE + 1
        x_base = chunk_x * CHUNK_SIZE
        y_base = chunk_y * CHUNK_SIZE
        
        # Create meshgrids for all vertex coordinates
        x_coords = np.arange(x_base, x_base + vertex_size, dtype=np.float32)
        y_coords = np.arange(y_base, y_base + vertex_size, dtype=np.float32)
        X, Y = np.meshgrid(x_coords, y_coords, indexing='xy')
        
        # Generate terrain for ALL vertices at once (65×65 = 4225 values in one call!)
        vertices = self._generate_tiles_vectorized(X, Y)
        
        # Sample corners for each tile (64×64 tiles)
        for y in range(CHUNK_SIZE):
            tile_row = []
            corner_row = []
            for x in range(CHUNK_SIZE):
                # Get 4 corners from vertex grid
                # 🔥 FIX: Convert np.int32 to Python int for JSON serialization
                nw = int(vertices[y, x])
                ne = int(vertices[y, x + 1])
                sw = int(vertices[y + 1, x])
                se = int(vertices[y + 1, x + 1])
                
                # Tile type = predominant terrain
                tile = max(nw, ne, sw, se)
                
                tile_row.append(tile)
                corner_row.append((nw, ne, sw, se))
            
            chunk.tiles.append(tile_row)
            chunk.tile_corners.append(corner_row)
        
        # Generate structures/entities for chunk
        self._generate_chunk_features(chunk)
        
        # Cache result (add to end)
        self.chunk_cache[chunk_key] = chunk
        
        # 🔥 MEMORY FIX: Evict oldest chunk if cache too large
        if len(self.chunk_cache) > self.MAX_CHUNK_CACHE:
            self.chunk_cache.popitem(last=False)  # Remove oldest (first) item
        
        return chunk
    
    def _generate_tiles_vectorized(self, X: np.ndarray, Y: np.ndarray) -> np.ndarray:
        """
        🔥 VECTORIZED: Generate terrain for all coordinates at once using NumPy!
        
        Args:
            X: 2D array of X coordinates (shape: [height, width])
            Y: 2D array of Y coordinates (shape: [height, width])
        
        Returns:
            2D array of terrain types (0=water, 1=wasteland, 2=desert)
        
        This is 50-100× faster than calling _generate_tile() in a loop!
        """
        height, width = X.shape
        
        # Flatten coordinates for batch noise generation
        x_flat = X.flatten()
        y_flat = Y.flatten()
        n_points = len(x_flat)
        
        # === ELEVATION (height map) with multi-octave noise ===
        elevation = np.zeros(n_points, dtype=np.float32)
        max_amp = 0.0
        amp = 1.0
        freq = self.height_scale
        
        for _ in range(self.octaves):
            # Prepare coordinates for this octave
            coords = np.stack([x_flat * freq, y_flat * freq], axis=0)
            # Generate noise for all points at once!
            noise_values = self.elevation_noise.gen_from_coords(coords)
            elevation += noise_values * amp
            max_amp += amp
            amp *= self.persistence
            freq *= 2.0
        
        # 🔥 FIXED: Global normalization (same scale for all chunks!)
        elevation = elevation / max_amp
        # FastNoiseLite returns roughly -1 to 1, normalize to 0-1 globally
        elevation = elevation * 0.5 + 0.5
        # Clip to ensure valid range
        elevation = np.clip(elevation, 0, 1)
        
        # === MOISTURE (wetness) ===
        moisture = np.zeros(n_points, dtype=np.float32)
        max_amp_m = 0.0
        amp_m = 1.0
        freq_m = self.moisture_scale
        
        moisture_octaves = max(2, self.octaves - 1)
        for _ in range(moisture_octaves):
            coords = np.stack([x_flat * freq_m, y_flat * freq_m], axis=0)
            noise_values = self.moisture_noise.gen_from_coords(coords)
            moisture += noise_values * amp_m
            max_amp_m += amp_m
            amp_m *= self.persistence
            freq_m *= 2.0
        
        # 🔥 FIXED: Global normalization (same scale for all chunks!)
        moisture = moisture / max_amp_m
        # FastNoiseLite returns roughly -1 to 1, normalize to 0-1 globally
        moisture = moisture * 0.5 + 0.5
        # Clip to ensure valid range
        moisture = np.clip(moisture, 0, 1)
        
        # === TERRAIN CLASSIFICATION ===
        # Target: ~15% water, ~65% wasteland, ~20% desert/ice
        # (Stricter thresholds = more wasteland)
        terrain = np.ones(n_points, dtype=np.int32) * self.TERRAIN_WASTELAND  # Default
        
        # Water (low elevation) - ~15% (stricter threshold)
        terrain[elevation < 0.35] = self.TERRAIN_WATER
        
        # Desert/Ice (high+dry OR very high) - ~20% (stricter threshold)
        desert_mask = ((elevation > 0.65) & (moisture < 0.40)) | (elevation > 0.75)
        terrain[desert_mask] = self.TERRAIN_DESERT
        
        # Reshape back to 2D grid
        return terrain.reshape(height, width)
    
    def _generate_tile(self, world_x: int, world_y: int) -> int:
        """
        Generate single tile based on world coordinates.
        Uses biome-based 3-layer terrain matching create_test_map.py logic.
        
        Returns:
            0 = TERRAIN_WATER (low elevation)
            1 = TERRAIN_WASTELAND (default/medium elevation)
            2 = TERRAIN_DESERT (high elevation + dry)
        
        🔥 LRU CACHED: Auto-evicts old tiles when cache full!
        """
        tile_key = (world_x, world_y)
        
        # Check cache first - move to end (mark as recently used)
        if tile_key in self.tile_cache:
            self.tile_cache.move_to_end(tile_key)
            return self.tile_cache[tile_key]
        
        # === ELEVATION (height map) ===
        # Multi-octave noise matching create_test_map.py sum_octaves function
        elevation = 0.0
        max_amp = 0.0
        amp = 1.0
        freq = self.height_scale
        
        for _ in range(self.octaves):
            # FastNoiseLite uses get_noise() instead of noise2()
            elevation += self.elevation_noise.get_noise(world_x * freq, world_y * freq) * amp
            max_amp += amp
            amp *= self.persistence
            freq *= 2.0
        
        # 🔥 FIXED: Global normalization (same scale for all chunks!)
        elevation = elevation / max_amp
        # FastNoiseLite returns roughly -1 to 1, normalize to 0-1 globally
        elevation = elevation * 0.5 + 0.5
        # Clip to ensure valid range
        elevation = max(0, min(1, elevation))
        
        # === MOISTURE (wetness) ===
        # Multi-octave noise (2 octaves for moisture variation)
        moisture = 0.0
        max_amp_m = 0.0
        amp_m = 1.0
        freq_m = self.moisture_scale
        
        moisture_octaves = max(2, self.octaves - 1)  # At least 2 octaves for moisture
        for _ in range(moisture_octaves):
            # FastNoiseLite uses get_noise() instead of noise2()
            moisture += self.moisture_noise.get_noise(world_x * freq_m, world_y * freq_m) * amp_m
            max_amp_m += amp_m
            amp_m *= self.persistence
            freq_m *= 2.0
        
        # 🔥 FIXED: Global normalization (same scale for all chunks!)
        moisture = moisture / max_amp_m
        # FastNoiseLite returns roughly -1 to 1, normalize to 0-1 globally
        moisture = moisture * 0.5 + 0.5
        # Clip to ensure valid range
        moisture = max(0, min(1, moisture))
        
        # === TERRAIN CLASSIFICATION ===
        # MUST MATCH _generate_tiles_vectorized() thresholds!
        terrain = self.TERRAIN_WASTELAND  # Default
        
        # Water (low elevation) - ~15% (stricter threshold)
        if elevation < 0.35:
            terrain = self.TERRAIN_WATER
        
        # Desert/Ice (high+dry OR very high) - ~20% (stricter threshold)
        elif (elevation > 0.65 and moisture < 0.40) or elevation > 0.75:
            terrain = self.TERRAIN_DESERT
        
        # Cache result (add to end)
        self.tile_cache[tile_key] = terrain
        
        # 🔥 MEMORY FIX: Evict oldest tile if cache too large
        if len(self.tile_cache) > self.MAX_TILE_CACHE:
            self.tile_cache.popitem(last=False)  # Remove oldest (first) item
        
        return terrain
    
    def _validate_tile_for_resource(self, tile_type: int, resource_type: str, tile_corners: Optional[Tuple[int, int, int, int]] = None) -> bool:
        """
        Validate if resource can spawn on given tile type.
        Using simplified 3-layer terrain system.
        """
        # Trees only spawn on pure wasteland (all 4 corners = 1)
        if resource_type == "tree":
            if tile_corners is None:
                return False
            nw, ne, sw, se = tile_corners
            return nw == 1 and ne == 1 and sw == 1 and se == 1
        
        # Resources don't spawn in water
        if tile_type == self.TERRAIN_WATER:
            return False
        
        # Other resources can spawn on wasteland or desert
        return True
    
    def _generate_chunk_features(self, chunk: Chunk):
        """Generate features like structures, resources, enemies in chunk"""
        
        # Resource spawning based on biome
        # Analyze chunk to determine dominant biome
        tile_counts = {}
        for row in chunk.tiles:
            for tile in row:
                tile_counts[tile] = tile_counts.get(tile, 0) + 1
        
        dominant_tile = max(tile_counts, key=tile_counts.get)
        
        # Spawn resources based on biome
        resource_spawns = []
        
        # Resource spawning based on dominant terrain
        if dominant_tile == self.TERRAIN_WATER:
            # No resources in water
            resource_spawns = []
        elif dominant_tile == self.TERRAIN_DESERT:
            # Desert - rocks and scrap
            # 🔥 Adjusted for CHUNK_SIZE=16 (1/4 of previous density since chunks are 4× smaller)
            resource_spawns = [
                ("rock", 1, 2),      # Was 3-6 for CHUNK_SIZE=32
                ("scrap_pile", 0, 1),  # Was 2-4 for CHUNK_SIZE=32
            ]
        else:  # TERRAIN_WASTELAND (default)
            # Wasteland - mix of everything
            # 🔥 Adjusted for CHUNK_SIZE=16 (1/4 of previous density)
            resource_spawns = [
                ("tree", 0, 1),      # Was 1-3 for CHUNK_SIZE=32
                ("rock", 0, 1),      # Was 2-4 for CHUNK_SIZE=32
                ("scrap_pile", 0, 1),  # Was 2-4 for CHUNK_SIZE=32
            ]
        
        # Generate resource positions within chunk
        # This creates (resource_type, x, y) tuples that will be spawned by EntityManager
        for resource_type, min_count, max_count in resource_spawns:
            count = self.rng.randint(min_count, max_count)
            
            attempts = 0
            max_attempts = count * 5  # Try up to 5x to find valid positions
            spawned = 0
            
            while spawned < count and attempts < max_attempts:
                attempts += 1
                
                # Random position within chunk (local coordinates)
                local_x = self.rng.randint(0, CHUNK_SIZE - 1)
                local_y = self.rng.randint(0, CHUNK_SIZE - 1)
                
                # Get tile at this position
                tile_at_pos = chunk.tiles[local_y][local_x]
                tile_corners = chunk.get_corners(local_x, local_y)
                
                # Validate resource can spawn on this tile
                if not self._validate_tile_for_resource(tile_at_pos, resource_type, tile_corners):
                    continue  # Try another position
                
                # Valid position - spawn resource
                world_x = chunk.chunk_x * CHUNK_SIZE + local_x
                world_y = chunk.chunk_y * CHUNK_SIZE + local_y
                
                # Convert to pixel coordinates
                pixel_x = world_x * TILE_SIZE + TILE_SIZE // 2
                pixel_y = world_y * TILE_SIZE + TILE_SIZE // 2
                
                # Deterministically select variant_id based on position + seed
                # This ensures all clients see same variants at same positions
                variant_id = None
                if resource_type in ["tree", "rock"]:
                    # Hash position with seed for deterministic selection
                    variant_id = (hash((world_x, world_y, self.seed)) % 10) + 1  # _01 to _10
                
                # Store resource spawn data in chunk
                entity_data = {
                    "type": "resource",
                    "resource_type": resource_type,
                    "x": pixel_x,
                    "y": pixel_y,
                }
                if variant_id is not None:
                    entity_data["variant_id"] = variant_id
                
                chunk.entities.append(entity_data)
                
                spawned += 1
    
    def get_spawn_position(self) -> Tuple[float, float]:
        """Get safe spawn position for players"""
        # Start near origin, on safe ground
        return (0.0, 0.0)


class World:
    """World manager - handles chunks and entities"""
    
    # Shared thread pool for all World instances (CPU-heavy chunk generation)
    # OPTIMIZED: Increased from 4 to 8 workers for faster parallel generation
    # Note: Lazy initialization to avoid Windows multiprocessing issues
    _thread_pool = None
    
    @classmethod
    def _get_thread_pool(cls):
        """Lazy initialization of thread pool (fixes Windows compatibility)"""
        if cls._thread_pool is None:
            cls._thread_pool = ThreadPoolExecutor(max_workers=8, thread_name_prefix="ChunkGen")
        return cls._thread_pool
    
    def __init__(self, seed: int, entity_manager=None, database: Optional['Database'] = None):
        """
        Initialize world
        
        Args:
            seed: World seed
            entity_manager: EntityManager instance for spawning entities
            database: Database instance for persistence (optional)
        """
        self.seed = seed
        
        # Day/Night cycle (starts at sunrise - 6:00 AM)
        from shared.constants import SUNRISE_TIME
        self.game_time = SUNRISE_TIME  # Game time in minutes (0-1439)
        
        # Use WorldGenerator for INFINITE SEAMLESS terrain
        # OpenSimplex provides truly infinite terrain without chunk boundary artifacts
        self.generator = WorldGenerator(seed)
        
        console.print(f"🌍 Using WorldGenerator (infinite seamless terrain)")
        
        self.chunks: Dict[Tuple[int, int], Chunk] = {}
        self.entity_manager = entity_manager
        self.database = database
        
        # Track which chunks have had their entities spawned
        self.spawned_chunks: Set[Tuple[int, int]] = set()
        
        # Async chunk generation tracking
        self.generating_chunks: Set[Tuple[int, int]] = set()  # Chunks currently being generated
        self.chunk_generation_tasks: Dict[Tuple[int, int], asyncio.Task] = {}  # Active generation tasks

        # Dirty tracking for persistence
        self._dirty_chunk_keys: Set[Tuple[int, int]] = set()
        self._dirty_chunks_lock = threading.Lock()
        
        console.print(f"🌍 World initialized (seed: {seed})")
        console.print(f"   🧵 Thread pool: 8 workers (parallel chunk generation)")
        if database:
            console.print(f"   💾 Persistence enabled")
    
    def get_chunk(self, chunk_x: int, chunk_y: int) -> Chunk:
        """
        Get chunk (synchronous - only returns if already loaded).
        For async loading, use get_chunk_async().
        """
        key = (chunk_x, chunk_y)
        
        if key not in self.chunks:
            # Return None if not loaded - don't block!
            return None
        
        return self.chunks[key]
    
    async def get_chunk_async(self, chunk_x: int, chunk_y: int) -> Chunk:
        """
        Get or generate chunk asynchronously.
        This runs chunk generation in the background without blocking game loop.
        
        IMPORTANT: Thread-safe - prevents duplicate generation when multiple players
        request the same chunk simultaneously!
        """
        key = (chunk_x, chunk_y)
        
        # Already loaded - fast path
        if key in self.chunks:
            return self.chunks[key]
        
        # Already generating - wait for existing task (prevents duplicate work!)
        if key in self.chunk_generation_tasks:
            return await self.chunk_generation_tasks[key]
        
        # Mark as generating BEFORE starting task (critical for race condition!)
        self.generating_chunks.add(key)
        
        try:
            # Try loading from database first
            if self.database:
                loaded_chunk = await self._load_chunk_from_db(chunk_x, chunk_y)
                if loaded_chunk:
                    self.chunks[key] = loaded_chunk
                    return loaded_chunk
            
            # Start new generation task
            task = asyncio.create_task(self._generate_chunk_async(chunk_x, chunk_y))
            self.chunk_generation_tasks[key] = task
            
            chunk = await task
            return chunk
        finally:
            # Clean up tracking (allows future re-generation if needed)
            self.generating_chunks.discard(key)
            if key in self.chunk_generation_tasks:
                del self.chunk_generation_tasks[key]
    
    async def _generate_chunk_async(self, chunk_x: int, chunk_y: int) -> Chunk:
        """
        Generate chunk asynchronously using thread pool.
        Runs CPU-heavy generation in background thread to avoid blocking event loop.
        """
        key = (chunk_x, chunk_y)
        self.generating_chunks.add(key)
        
        try:
            # Run CPU-heavy chunk generation in thread pool
            loop = asyncio.get_event_loop()
            chunk = await loop.run_in_executor(
                World._get_thread_pool(),
                self._generate_chunk_in_thread,
                chunk_x,
                chunk_y
            )
            
            # Store chunk (back in main thread)
            self.chunks[key] = chunk

            # Mark newly generated chunks dirty so they get persisted once.
            # (Chunks loaded from DB are clean; only generation path marks dirty.)
            if getattr(chunk, "_persist_rev", 0) == 0:
                chunk._persist_rev = 1
            with self._dirty_chunks_lock:
                self._dirty_chunk_keys.add(key)
            
            # Spawn entities if EntityManager available (main thread only)
            if self.entity_manager and key not in self.spawned_chunks:
                self._spawn_chunk_entities(chunk)
                self.spawned_chunks.add(key)
            
            return chunk
            
        finally:
            self.generating_chunks.discard(key)
    
    def _generate_chunk_in_thread(self, chunk_x: int, chunk_y: int) -> Chunk:
        """
        Generate chunk synchronously in background thread.
        🔥 OPTIMIZED: Now uses vectorized generation!
        """
        # Use the fast vectorized generate_chunk method
        chunk = self.generator.generate_chunk(chunk_x, chunk_y)
        
        return chunk
    
    def _spawn_chunk_entities(self, chunk: Chunk):
        """Spawn entities for a chunk"""
        # Lazy import to avoid circular dependency
        try:
            from server.entities import ResourceNode
        except ImportError:
            console.print("⚠️ Could not import ResourceNode")
            return
        
        for entity_data in chunk.entities:
            if entity_data["type"] == "resource":
                try:
                    # Create ResourceNode entity
                    node = ResourceNode(
                        id=0,  # Will be assigned by EntityManager
                        resource_type=entity_data["resource_type"],
                        x=entity_data["x"],
                        y=entity_data["y"],
                        variant_id=entity_data.get("variant_id"),  # Unified variant ID
                        biome_id=getattr(self.generator, "biome_id", None),
                    )
                    self.entity_manager.add_entity(node)
                    # console.print(f"🌳 Spawned {entity_data['resource_type']} at ({entity_data['x']}, {entity_data['y']})")
                except Exception as e:
                    console.print(f"⚠️ Error spawning resource: {e}")
    
    def get_tile(self, world_x: int, world_y: int) -> int:
        """Get tile at world coordinates"""
        chunk_x = world_x // CHUNK_SIZE
        chunk_y = world_y // CHUNK_SIZE
        local_x = world_x % CHUNK_SIZE
        local_y = world_y % CHUNK_SIZE
        
        chunk = self.get_chunk(chunk_x, chunk_y)
        return chunk.get_tile(local_x, local_y)
    
    def get_spawn_position(self) -> Tuple[float, float]:
        """Get spawn position for new players"""
        return self.generator.get_spawn_position()
    
    def unload_chunk(self, chunk_x: int, chunk_y: int):
        """Unload chunk from memory"""
        key = (chunk_x, chunk_y)
        if key in self.chunks:
            del self.chunks[key]
    
    def get_loaded_chunks(self) -> List[Tuple[int, int]]:
        """Get list of loaded chunk coordinates"""
        return list(self.chunks.keys())
    
    async def _load_chunk_from_db(self, chunk_x: int, chunk_y: int) -> Optional[Chunk]:
        """Load chunk from database"""
        if not self.database:
            return None
        
        # Run DB operation in thread pool to avoid blocking event loop
        loop = asyncio.get_event_loop()
        chunk_data = await loop.run_in_executor(None, self.database.load_chunk, chunk_x, chunk_y)
        
        if chunk_data:
            tiles, corners = chunk_data
            chunk = Chunk(chunk_x, chunk_y)
            chunk.tiles = tiles
            chunk.tile_corners = corners if corners else []

            # Loaded chunks are clean.
            chunk._persist_rev = 0
            chunk._persist_saved_rev = 0
            
            # If corners missing (old save), regenerate chunk
            if not corners:
                console.print(f"⚠️ Old save format for chunk ({chunk_x}, {chunk_y}), regenerating...")
                return None  # Trigger fresh generation with vertex sampling
            
            # Note: entities are loaded separately by EntityManager
            return chunk
        
        return None
    
    def save_chunk(self, chunk_x: int, chunk_y: int):
        """Save chunk to database"""
        if not self.database:
            return
        
        key = (chunk_x, chunk_y)
        if key not in self.chunks:
            return
        
        chunk = self.chunks[key]
        self.database.save_chunk(chunk_x, chunk_y, chunk.tiles, chunk.tile_corners)

        # Best-effort: mark clean if this save matches current revision.
        rev = getattr(chunk, "_persist_rev", 0)
        chunk._persist_saved_rev = rev
        with self._dirty_chunks_lock:
            self._dirty_chunk_keys.discard(key)
    
    def save_all_chunks(self, *, force: bool = False) -> int:
        """Save dirty chunks to database (or all chunks with force=True)."""
        if not self.database:
            return 0

        if force:
            keys_to_save = list(self.chunks.keys())
        else:
            with self._dirty_chunks_lock:
                keys_to_save = list(self._dirty_chunk_keys)

        if not keys_to_save:
            return 0

        saved_count = 0
        saved_keys: list[Tuple[int, int]] = []

        for key in keys_to_save:
            chunk = self.chunks.get(key)
            if not chunk:
                saved_keys.append(key)
                continue

            rev = getattr(chunk, "_persist_rev", 0)
            saved_rev = getattr(chunk, "_persist_saved_rev", 0)
            if rev <= saved_rev and not force:
                saved_keys.append(key)
                continue

            chunk_x, chunk_y = key
            self.database.save_chunk(chunk_x, chunk_y, chunk.tiles, chunk.tile_corners)
            saved_count += 1
            if chunk.mark_clean_if_unchanged(rev):
                saved_keys.append(key)

        if saved_keys and not force:
            with self._dirty_chunks_lock:
                for key in saved_keys:
                    self._dirty_chunk_keys.discard(key)

        if saved_count > 0:
            console.print(f"💾 Saved {saved_count} dirty chunks to database")

        return saved_count
    
    def get_environmental_exposure(self, x: float, y: float) -> dict:
        """
        Calculate environmental exposure at given world position.
        Returns exposure levels (0.0-1.0) for each environmental hazard.
        
        Args:
            x: World X position (pixels)
            y: World Y position (pixels)
        
        Returns:
            Dict with exposure levels: {"radiation": 0.0-1.0, "cold": 0.0-1.0}
        """
        from shared.constants import ENVIRONMENT_WATER_RADIUS
        
        exposure = {"radiation": 0.0, "cold": 0.0}
        
        if self.generator.biome_id == "toxic_wasteland":
            # Base ambient radiation everywhere (30%)
            base_radiation = 0.3
            
            # Extra radiation near toxic water (tile type 0)
            min_water_dist = self._find_nearest_water_distance(x, y, ENVIRONMENT_WATER_RADIUS)
            
            if min_water_dist < ENVIRONMENT_WATER_RADIUS:
                # Map distance to extra exposure: 0px = +70%, 640px = +0%
                proximity = 1.0 - min(min_water_dist / ENVIRONMENT_WATER_RADIUS, 1.0)
                extra_radiation = proximity * 0.7
                exposure["radiation"] = base_radiation + extra_radiation  # 30-100%
            else:
                exposure["radiation"] = base_radiation  # 30% baseline
        
        elif self.generator.biome_id == "frozen_world":
            # Base ambient cold everywhere (30%)
            base_cold = 0.3
            
            # Extra cold on ice (layer 2)
            tile_x = int(x / TILE_SIZE)
            tile_y = int(y / TILE_SIZE)
            terrain_type = self._get_terrain_at_position(tile_x, tile_y)
            
            if terrain_type == 2:  # Ice (top layer)
                exposure["cold"] = 0.8  # Heavy cold on ice (80%)
            else:
                exposure["cold"] = base_cold  # 30% baseline everywhere else
        
        # Future: Add magma world (similar to toxic_wasteland but with "magma" instead of water)
        
        return exposure
    
    def _find_nearest_water_distance(self, x: float, y: float, search_radius: float) -> float:
        """Find distance to nearest water tile within search radius"""
        tile_x = int(x / TILE_SIZE)
        tile_y = int(y / TILE_SIZE)
        
        search_tiles = int(search_radius / TILE_SIZE)
        min_dist = 999999.0
        
        for dx in range(-search_tiles, search_tiles + 1):
            for dy in range(-search_tiles, search_tiles + 1):
                check_x = tile_x + dx
                check_y = tile_y + dy
                
                terrain = self._get_terrain_at_position(check_x, check_y)
                if terrain == 0:  # Water tile
                    world_x = check_x * TILE_SIZE
                    world_y = check_y * TILE_SIZE
                    dist = math.sqrt((x - world_x)**2 + (y - world_y)**2)
                    min_dist = min(min_dist, dist)
        
        return min_dist
    
    def _get_terrain_at_position(self, tile_x: int, tile_y: int) -> int:
        """Get terrain type at tile position (0=water, 1=middle, 2=top)"""
        chunk_x = tile_x // CHUNK_SIZE
        chunk_y = tile_y // CHUNK_SIZE
        chunk_key = (chunk_x, chunk_y)
        
        if chunk_key in self.chunks:
            local_x = tile_x % CHUNK_SIZE
            local_y = tile_y % CHUNK_SIZE
            return self.chunks[chunk_key].get_tile(local_x, local_y)
        
        # Chunk not loaded - sample directly from noise
        corners = self.generator._generate_tiles_vectorized(
            np.array([[tile_x]], dtype=np.float32),
            np.array([[tile_y]], dtype=np.float32)
        )
        return corners[0][0]
    
    def update_time(self, delta_time: float):
        """
        Update game time (day/night cycle)
        
        Args:
            delta_time: Time elapsed in seconds (real time)
        """
        from shared.constants import GAME_MINUTES_PER_REAL_SECOND, DAY_DURATION_MINUTES
        
        # Convert real seconds to game minutes
        game_minutes_elapsed = delta_time * GAME_MINUTES_PER_REAL_SECOND
        self.game_time += game_minutes_elapsed
        
        # Wrap around after 24 hours (1440 minutes)
        if self.game_time >= DAY_DURATION_MINUTES:
            self.game_time -= DAY_DURATION_MINUTES
    
    def get_time_of_day(self) -> str:
        """Get human-readable time of day (e.g., '14:30')"""
        hours = int(self.game_time // 60) % 24
        minutes = int(self.game_time % 60)
        return f"{hours:02d}:{minutes:02d}"
