"""
STARWASTE - Server Main
Game server with asyncio networking
"""

# ============================================================================
# DEBUG CONFIGURATION - Set to False for production (maximum performance!)
# ============================================================================
DEBUG_ENABLED = True  # Master switch - disables ALL debug output

# Individual debug categories (only work if DEBUG_ENABLED = True)
DEBUG_INIT = True      # Server startup messages
DEBUG_CHUNKS = False    # Chunk generation/loading messages
DEBUG_NETWORK = False   # Network request/response messages
DEBUG_PLAYERS = True   # Player connect/disconnect messages
DEBUG_STATS = False    # Periodic stats (TPS, entities, chunks) - prints every ~5s
DEBUG_SAVES = True     # World save messages
DEBUG_HARVEST = False   # Harvesting messages

def debug_print(category: bool, *args, **kwargs):
    """Print only if DEBUG_ENABLED and category is True"""
    if DEBUG_ENABLED and category:
        console.print(*args, **kwargs)

import asyncio
import msgpack
import struct
import time
import signal
from typing import Dict, Set, Tuple, Optional
from pathlib import Path
from shared.utils import console
from shared.protocol import Protocol
from server.world import World
from server.entities import EntityManager, Player
from server.database import Database
from server.collision import CollisionManager
from shared.constants import DEFAULT_PORT, TICK_RATE, VERSION, CHUNK_SIZE, TILE_SIZE, TILE_WALKABLE
from shared.utils import seed_from_string
import os
from dotenv import load_dotenv

load_dotenv()


class GameServer:
    """Main game server"""
    STATE_UPDATE_RADIUS = CHUNK_SIZE * TILE_SIZE * 3  # Send entities within ~3 chunks (per player)
    AUTO_SAVE_INTERVAL = 120.0  # Auto-save every 2 minutes (reduced disk I/O)
    IDLE_SLEEP_SECONDS = 0.25   # When no players: freeze simulation & reduce CPU
    CHUNK_KEEP_RADIUS = 5  # Keep chunks within 5 chunks of any player, unload rest
    MAX_CHUNKS_PER_REQUEST = 64  # Safety cap to prevent huge CPU/ram spikes
    
    def __init__(self, host: str = "localhost", port: int = DEFAULT_PORT, seed: str = None, new_world: bool = False):
        """
        Initialize game server
        
        Args:
            host: Host address
            port: Port number
            seed: World seed (None = use existing or random)
            new_world: Force create new world (delete existing save)
        """
        self.host = host
        self.port = port
        
        # Database setup
        self.database = Database("world.db")
        self.database.connect()
        
        # Check if we should start fresh
        if new_world and self.database.world_exists():
            debug_print(DEBUG_INIT, "🗑️ Creating new world (deleting existing save)...")
            self.database.clear_all()
        
        # Load existing world or create new
        world_metadata = self.database.load_world_metadata()
        if world_metadata:
            # Load existing world
            world_seed = world_metadata["seed"]
            saved_game_time = world_metadata.get("game_time", 360.0)
            debug_print(DEBUG_INIT, f"💾 Loading existing world (seed: {world_seed})")
            debug_print(DEBUG_INIT, f"   Created: {time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(world_metadata['created_at']))}")
            debug_print(DEBUG_INIT, f"   Last save: {time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(world_metadata['last_save']))}")
        else:
            # Create new world
            if seed:
                # Use provided seed
                seed_str = seed
            elif os.getenv("WORLD_SEED"):
                # Use environment variable seed
                seed_str = os.getenv("WORLD_SEED")
            else:
                # Generate random seed from current timestamp
                seed_str = f"starwaste_{int(time.time() * 1000)}"
            
            world_seed = seed_from_string(seed_str)
            debug_print(DEBUG_INIT, f"🆕 Creating new world (seed: {world_seed}, seed_str: '{seed_str}')")
            self.database.save_world_metadata(world_seed, game_time=360.0)
        
        # Initialize world and entities with database
        self.entity_manager = EntityManager(database=self.database)
        # Pass world seed to entity manager (needed for regenerating tree_id from DB)
        self.entity_manager._world_seed = world_seed
        self.world = World(world_seed, entity_manager=self.entity_manager, database=self.database)
        # Pass world biome_id to entity manager (used as fallback for per-biome loot)
        self.entity_manager._world_biome_id = getattr(self.world.generator, "biome_id", None)
        
        # Load saved game time if available
        if world_metadata:
            self.world.game_time = saved_game_time
        
        # Initialize collision system
        self.collision_manager = CollisionManager()
        self.collision_manager.load_all_biomes()
        
        # Load entities from database
        if world_metadata:
            self.entity_manager.load_entities_from_db()
        
        # Networking
        self.clients: Dict[asyncio.StreamWriter, Player] = {}
        self.running = False
        
        # Timing
        self.tick_rate = TICK_RATE
        self.tick_duration = 1.0 / self.tick_rate
        
        # Debug stats (throttled logging)
        self.last_debug_time = 0
        self.debug_interval = 5.0  # Log stats every 5 seconds

        # Per-player network telemetry (updated in send_state_updates, printed in _log_server_stats)
        # writer -> {"entities_sent": int, "nearby_non_players": int, "payload_bytes": int}
        self._last_state_update_stats: Dict[asyncio.StreamWriter, dict] = {}

        # Inventory sync (event-driven): send only when inventory changes.
        self._writer_by_player_id: Dict[int, asyncio.StreamWriter] = {}
        self._dirty_inventory_player_ids: Set[int] = set()
        # player_id -> last packed inventory_update message bytes
        self._last_inventory_update_by_player_id: Dict[int, bytes] = {}
        
        # Auto-save
        self.last_save_time = time.time()  # Initialize to now (first save after 60s)
        
        debug_print(DEBUG_INIT, f"🚀 STARWASTE Server v{VERSION}")
        debug_print(DEBUG_INIT, f"   Host: {host}:{port}")
        debug_print(DEBUG_INIT, f"   World seed: {world_seed}")
        
        # Chunk loading will happen on demand when players move around
        debug_print(DEBUG_INIT, f"✅ World generation ready (using pure Python noise)")
    
    async def start(self):
        """Start the server"""
        try:
            server = await asyncio.start_server(
                self.handle_client,
                self.host,
                self.port
            )
        except OSError as e:
            # Common: [Errno 48] address already in use
            console.print(f"❌ Failed to start server on {self.host}:{self.port}: {e}")
            self.running = False
            return
        
        self.running = True
        
        addr = ', '.join(str(sock.getsockname()) for sock in server.sockets)
        debug_print(DEBUG_INIT, f'✅ Server running on {addr}')
        debug_print(DEBUG_INIT, f'   Waiting for players...')
        
        # Start game loop (auto-save will trigger after 60s)
        game_loop_task = asyncio.create_task(self.game_loop())
        
        try:
            async with server:
                await server.serve_forever()
        finally:
            # Ensure save on shutdown
            if self.running:
                self.stop()
    
    async def handle_client(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter):
        """Handle client connection"""
        addr = writer.get_extra_info('peername')
        debug_print(DEBUG_PLAYERS, f'👤 New connection from {addr}')
        
        try:
            # Receive player info (MessagePack stream)
            unpacker = Protocol.create_unpacker(raw=False)
            # Read until we get at least one object (the player_info)
            while True:
                try:
                    chunk = await asyncio.wait_for(reader.read(4096), timeout=5.0)
                except asyncio.TimeoutError:
                    return
                if not chunk:
                    return
                unpacker.feed(chunk)
                for obj in unpacker:
                    player_info = obj
                    break
                if 'player_info' in locals():
                    break
            username = player_info.get("username", f"Player{len(self.clients) + 1}")
            
            # Try to load saved player data from database
            saved_player = None
            if self.database:
                saved_player = self.database.load_player(username)
            
            # Create player entity (use saved position if available)
            if saved_player:
                spawn_x = saved_player["x"]
                spawn_y = saved_player["y"]
                debug_print(DEBUG_PLAYERS, f'   Player "{username}" returning (ID will be assigned, last pos: {spawn_x:.0f}, {spawn_y:.0f})')
            else:
                spawn_x, spawn_y = self.world.get_spawn_position()
                debug_print(DEBUG_PLAYERS, f'   New player "{username}" spawning at default position')
            
            player = Player(0, username, spawn_x, spawn_y)
            player_id = self.entity_manager.add_entity(player)
            
            # Restore saved data if available
            if saved_player:
                from shared.inventory import Inventory
                player.health = saved_player["data"].get("health", player.health)
                player.hunger = saved_player["data"].get("hunger", player.hunger)
                player.radiation = saved_player["data"].get("radiation", player.radiation)
                player.cold = saved_player["data"].get("cold", player.cold)
                
                # Restore inventory from saved data
                inventory_data = saved_player["data"].get("inventory")
                if inventory_data:
                    player.inventory = Inventory.from_dict(inventory_data)
            else:
                # New player - initialize environmental status based on biome
                from shared.constants import MAX_RADIATION, MAX_COLD
                exposure = self.world.get_environmental_exposure(spawn_x, spawn_y)
                player.radiation = int(exposure["radiation"] * MAX_RADIATION)
                player.cold = int(exposure["cold"] * MAX_COLD)
            
            debug_print(DEBUG_PLAYERS, f'   Player "{username}" joined (ID: {player_id})')

            # Send welcome message (connection/session metadata).
            # Terrain/chunks are streamed separately via chunk_request/chunk_data.
            welcome = {
                "type": "welcome",
                "player_id": player_id,
                "spawn_x": spawn_x,
                "spawn_y": spawn_y,
                "world_seed": self.world.seed,
                "biome_id": self.world.generator.biome_id
            }
            packed = msgpack.packb(welcome, use_bin_type=True)
            writer.write(packed)
            await writer.drain()
            
            debug_print(DEBUG_PLAYERS, f'   Sent welcome (seed: {self.world.seed}) to {username}')
            
            # Register client only after successful welcome
            self.clients[writer] = player
            self._writer_by_player_id[player_id] = writer

            # Send initial inventory snapshot once (separate message; state_update is inventory-free).
            try:
                await self._send_inventory_update(writer, player, force=True)
            except Exception:
                pass
            
            # Broadcast player joined
            await self.broadcast({
                "type": "player_joined",
                "player_id": player_id,
                "username": username
            }, exclude=writer)
            
            # Handle client messages using streaming Unpacker
            unpacker = Protocol.create_unpacker(raw=False)
            while self.running:
                try:
                    try:
                        chunk = await asyncio.wait_for(reader.read(4096), timeout=60.0)
                    except asyncio.TimeoutError:
                        # Send ping
                        packed_ping = msgpack.packb({"type": "ping"}, use_bin_type=True)
                        writer.write(packed_ping)
                        await writer.drain()
                        continue

                    if not chunk:
                        break

                    unpacker.feed(chunk)
                    for message in unpacker:
                        try:
                            await self.handle_message(writer, player, message)
                        except Exception as e:
                            debug_print(DEBUG_NETWORK, f'   Error handling message: {e}')
                            # continue processing remaining messages
                            continue

                except (ConnectionResetError, BrokenPipeError, ConnectionAbortedError) as e:
                    # Client disconnected abruptly - this is normal
                    break
                except Exception as e:
                    debug_print(DEBUG_PLAYERS, f'   Error in client loop: {e}')
                    break
        
        except Exception as e:
            debug_print(DEBUG_PLAYERS, f'   Connection error: {e}')
        
        finally:
            # Clean up
            if writer in self.clients:
                player = self.clients[writer]
                debug_print(DEBUG_PLAYERS, f'   Player "{player.username}" disconnected')
                
                # Save player data before removing
                if self.database:
                    self._save_player(player)
                    self.database.save_all()  # 🔥 FIX: Commit player data on disconnect
                
                self.entity_manager.remove_entity(player.id)
                del self.clients[writer]

                self._writer_by_player_id.pop(player.id, None)
                self._dirty_inventory_player_ids.discard(player.id)
                self._last_inventory_update_by_player_id.pop(player.id, None)

                # If this was the last player, persist game_time (and any dirty world data)
                # once, then the server can idle/freeze without periodic saves.
                # IMPORTANT: Do NOT force-save everything here (that would rewrite all loaded
                # chunks/entities even if nothing changed).
                if not self.clients and self.database:
                    try:
                        await self.save_world(force=False, allow_without_players=True)
                    except Exception:
                        pass
                
                await self.broadcast({
                    "type": "player_left",
                    "player_id": player.id
                })
            
            # Close writer gracefully
            try:
                writer.close()
                await writer.wait_closed()
            except (ConnectionResetError, BrokenPipeError, OSError) as e:
                # Client already disconnected, that's fine
                pass
            except Exception as e:
                debug_print(DEBUG_PLAYERS, f"   Error closing connection: {e}")
    
    async def handle_message(self, writer: asyncio.StreamWriter, player: Player, message: dict):
        """Handle client message"""
        msg_type = message.get("type")
        
        if msg_type == "move":
            # Update player velocity
            player.vx = message.get("vx", 0)
            player.vy = message.get("vy", 0)
        
        elif msg_type == "ping":
            # Respond with pong
            pong = {"type": "pong"}
            packed = msgpack.packb(pong, use_bin_type=True)
            writer.write(packed)
            await writer.drain()
        
        elif msg_type == "chat":
            # Broadcast chat message with server timestamp
            await self.broadcast({
                "type": "chat",
                "player_id": player.id,
                "username": player.username,
                "message": message.get("message", ""),
                "timestamp": time.time()  # Server timestamp for global ordering
            })
        
        elif msg_type == "harvest_start":
            # Start harvesting resource
            await self.handle_harvest_start(player, message)

        elif msg_type == "pickup_item":
            await self.handle_pickup_item(player, message)
            # Inventory may change.
            self._mark_inventory_dirty(player.id)

        elif msg_type == "drop_item":
            if self.handle_drop_item(player, message):
                self._mark_inventory_dirty(player.id)

        elif msg_type == "inventory_move":
            if self.handle_inventory_move(player, message):
                self._mark_inventory_dirty(player.id)
        
        elif msg_type == "harvest_cancel":
            # Cancel harvesting
            try:
                from server.entities import ResourceNode
                prev_target_id = getattr(player, "harvesting_target", None)
                if prev_target_id is not None:
                    target = self.entity_manager.get_entity(prev_target_id)
                    if target is not None and isinstance(target, ResourceNode):
                        target.cancel_harvest()
            except Exception:
                pass

            player.harvesting_target = None
            player.harvest_progress = 0.0
        
        elif msg_type == "chunk_request":
            # Client requesting chunks
            await self.handle_chunk_request(writer, message)
        
        elif msg_type == "pong":
            # Ping response
            pass
    
    async def handle_chunk_request(self, writer: asyncio.StreamWriter, message: dict):
        """Handle chunk data request from client"""
        import time
        request_start = time.perf_counter()
        
        chunks_requested = message.get("chunks", [])

        if not isinstance(chunks_requested, list):
            return

        # Safety cap: prevent massive requests causing expensive generation/serialization.
        if len(chunks_requested) > self.MAX_CHUNKS_PER_REQUEST:
            chunks_requested = chunks_requested[: self.MAX_CHUNKS_PER_REQUEST]
        
        if not chunks_requested:
            return
        
        player = self.clients.get(writer)
        player_name = player.username if player else "Unknown"
        
        if DEBUG_NETWORK:
            console.print(f"📥 {player_name} requested {len(chunks_requested)} chunks: {chunks_requested[:3]}{'...' if len(chunks_requested) > 3 else ''}")
        
        # IMPORTANT: Chunk payloads can get large (more terrain layers/corners/etc.).
        # If we send a single huge chunk_data message over TCP, it can delay state_update
        # (head-of-line blocking), making movement feel like it "sticks" despite 60 FPS.
        # Stream chunk_data in small batches to keep the connection responsive.
        CHUNK_BATCH_SIZE = 2
        chunks_data = {}
        generation_count = 0
        cache_hits = 0
        total_generation_time = 0.0

        async def flush_chunk_batch(batch: dict):
            if not batch:
                return
            response = {
                "type": "chunk_data",
                "chunks": batch,
            }
            packed = msgpack.packb(response, use_bin_type=True)
            writer.write(packed)
            await writer.drain()
            return len(packed)
        
        total_payload_bytes = 0
        for chunk_key in chunks_requested:
            if not isinstance(chunk_key, str) or len(chunk_key) > 32:
                continue
            # Parse "x,y" format
            parts = chunk_key.split(",")
            if len(parts) != 2:
                continue
            
            try:
                chunk_x = int(parts[0])
                chunk_y = int(parts[1])
                
                # Check if already loaded (for stats)
                was_cached = (chunk_x, chunk_y) in self.world.chunks
                
                # 🔥 Generate chunk asynchronously (uses server's cached OpenSimplex!)
                gen_start = time.perf_counter()
                chunk = await self.world.get_chunk_async(chunk_x, chunk_y)
                gen_time = (time.perf_counter() - gen_start) * 1000
                
                if was_cached:
                    cache_hits += 1
                else:
                    generation_count += 1
                    total_generation_time += gen_time
                
                # Serialize chunk data (tiles + corners + entities)
                chunks_data[chunk_key] = {
                    "tiles": chunk.tiles,  # 2D list of terrain types
                    "corners": chunk.tile_corners,  # 2D list of (NW, NE, SW, SE) tuples
                    "entities": chunk.entities  # Resource spawns with tree_id for trees
                }

                if len(chunks_data) >= CHUNK_BATCH_SIZE:
                    total_payload_bytes += await flush_chunk_batch(chunks_data) or 0
                    chunks_data = {}
            except ValueError:
                continue
        
        # Flush remaining batch
        if chunks_data:
            total_payload_bytes += await flush_chunk_batch(chunks_data) or 0

        total_time = (time.perf_counter() - request_start) * 1000

        # 🔥 PERFORMANCE LOG: Zobraz timing pokud trvalo >50ms nebo DEBUG_NETWORK
        if total_time > 50 or DEBUG_NETWORK:
            console.print(f"⏱️  SERVER CHUNK REQUEST: {len(chunks_requested)} chunks for {player_name}")
            console.print(f"   Generation: {total_generation_time:.2f}ms ({generation_count} new, {cache_hits} cached)")
            console.print(f"   Payload (streamed): {total_payload_bytes} bytes")
            console.print(f"   Total: {total_time:.2f}ms")
    
    async def handle_harvest_start(self, player: Player, message: dict):
        """Handle harvest start request"""
        from shared.constants import HARVEST_RANGE_SERVER, HARVEST_DAMAGE
        from server.entities import ResourceNode
        
        resource_id = message.get("resource_id")
        if not resource_id:
            return
        
        # Get resource entity
        resource = self.entity_manager.get_entity(resource_id)
        if not resource or not isinstance(resource, ResourceNode):
            return
        
        # Check distance (simple anti-cheat with 100px safety margin)
        dx = resource.x - player.x
        dy = resource.y - player.y
        distance = (dx*dx + dy*dy) ** 0.5
        
        if distance > HARVEST_RANGE_SERVER:
            debug_print(DEBUG_PLAYERS, f"⚠️ {player.username} too far from resource (distance: {distance:.1f})")
            return
        
        # TODO: Check tool requirement
        # if resource.required_tool and not player.has_tool(resource.required_tool):
        #     return
        
        # Start harvesting (only if not already harvesting this resource)
        if player.harvesting_target != resource_id:
            player.harvesting_target = resource_id
            player.harvest_progress = 0.0
            resource.start_harvest(harvester_id=player.id)
            debug_print(DEBUG_PLAYERS, f"⛏️ {player.username} started harvesting {resource.resource_type}")
        # else: already harvesting this resource, just continue
    
    async def update_harvesting(self, dt: float):
        """Update harvesting progress for all players
        
        🔥 OPTIMIZED: Use cached _players dict instead of clients.values()
        """
        for player in self.entity_manager._players.values():
            if player.harvesting_target is not None:
                # Find target entity
                target = self.entity_manager.get_entity(player.harvesting_target)
                
                if target is None:
                    # Target no longer exists
                    player.harvesting_target = None
                    player.harvest_progress = 0.0
                    continue
                
                # Increase harvest progress
                player.harvest_progress = min(1.0, player.harvest_progress + dt * 0.5)  # 2 seconds to harvest

                # Mirror progress to the resource entity so other clients can see who is harvesting.
                try:
                    if hasattr(target, "being_harvested"):
                        target.being_harvested = True
                    if hasattr(target, "harvester_id"):
                        target.harvester_id = int(player.id)
                    if hasattr(target, "harvest_time") and getattr(target, "harvest_time", 0) and hasattr(target, "harvest_progress"):
                        target.harvest_progress = float(player.harvest_progress) * float(getattr(target, "harvest_time", 0) or 0)
                except Exception:
                    pass
                
                # Check if harvest complete
                if player.harvest_progress >= 1.0:
                    # Harvest complete - damage resource
                    from shared.constants import HARVEST_DAMAGE
                    destroyed = target.take_damage(HARVEST_DAMAGE)

                    # Generate loot if resource destroyed
                    if destroyed:
                        from server.entities import Item
                        import random

                        drops = target.get_drops()
                        inventory_changed = False
                        for item_id, quantity in drops:
                            remaining = player.inventory.add_item(item_id, quantity)
                            added = quantity - remaining

                            if added > 0:
                                debug_print(DEBUG_HARVEST, f"✅ {player.username} harvested {added}× {item_id}")
                                inventory_changed = True

                            if remaining > 0:
                                # Spawn overflow as ground item entity near the node
                                drop_x = target.x + random.uniform(-12, 12)
                                drop_y = target.y + random.uniform(-12, 12)
                                drop = Item(0, item_id=item_id, x=drop_x, y=drop_y)
                                drop.set_quantity(remaining)
                                drop_id = self.entity_manager.add_entity(drop)
                                # Persist soon-ish (autosave still exists, but this makes drops survive crashes)
                                if self.database:
                                    self.entity_manager.save_entity(drop_id)

                        # Remove harvested resource entity
                        self.entity_manager.remove_entity(target.id)

                        if inventory_changed:
                            self._mark_inventory_dirty(player.id)

                    # Reset harvest state
                    player.harvesting_target = None
                    player.harvest_progress = 0.0

                    # Clear resource harvesting markers if it still exists.
                    try:
                        if not destroyed and hasattr(target, "cancel_harvest"):
                            target.cancel_harvest()
                    except Exception:
                        pass

    async def handle_pickup_item(self, player: Player, message: dict):
        """Handle item pickup request (server-authoritative)."""
        from shared.constants import PICKUP_RANGE_SERVER, PICKUP_VERTICAL_BONUS
        from server.entities import Item

        entity_id = message.get("entity_id")
        if not entity_id:
            return

        entity = self.entity_manager.get_entity(entity_id)
        if not entity or not isinstance(entity, Item):
            return

        # Distance check (anti-cheat)
        dx = entity.x - player.x
        dy = entity.y - player.y
        max_range = float(PICKUP_RANGE_SERVER)
        if abs(dy) > abs(dx):
            max_range += float(PICKUP_VERTICAL_BONUS)
        if (dx * dx + dy * dy) ** 0.5 > max_range:
            return

        item_id = getattr(entity, "item_id", None) or getattr(entity, "item_type", "")
        if not item_id:
            return

        qty = max(1, int(getattr(entity, "quantity", 1)))
        remaining = player.inventory.add_item(item_id, qty)
        added = qty - remaining
        if added <= 0:
            return

        self._mark_inventory_dirty(player.id)

        # Update / remove item entity based on remaining quantity
        if remaining <= 0:
            self.entity_manager.remove_entity(entity.id)
        else:
            if hasattr(entity, "set_quantity"):
                entity.set_quantity(remaining)
            else:
                entity.quantity = remaining
                self.entity_manager.mark_entity_dirty(entity.id)
            if self.database:
                self.entity_manager.save_entity(entity.id)

    def handle_inventory_move(self, player: Player, message: dict) -> bool:
        """Handle inventory move/equip request (server-authoritative)."""
        from shared.items import EquipmentSlot
        from shared.inventory import ItemStack

        src_type = message.get("src_type")
        dst_type = message.get("dst_type")
        src_slot = message.get("src_slot")
        dst_slot = message.get("dst_slot")

        if src_type not in ("main", "equip") or dst_type not in ("main", "equip"):
            return False

        if src_type == dst_type and src_slot == dst_slot:
            return False

        inv = player.inventory

        def parse_equip_slot(value) -> Optional[EquipmentSlot]:
            if value is None:
                return None
            try:
                return EquipmentSlot(value)
            except Exception:
                return None

        def get_stack(loc_type: str, loc_slot):
            if loc_type == "main":
                if not isinstance(loc_slot, int) or loc_slot < 0 or loc_slot >= inv.main_slots:
                    return None
                return inv.get_item_stack(loc_slot)
            equip = parse_equip_slot(loc_slot)
            if not equip:
                return None
            return inv.get_equipped(equip)

        def set_stack(loc_type: str, loc_slot, stack: Optional[ItemStack]):
            if loc_type == "main":
                if not isinstance(loc_slot, int) or loc_slot < 0 or loc_slot >= inv.main_slots:
                    return False
                inv.set_item_stack(loc_slot, stack)
                return True
            equip = parse_equip_slot(loc_slot)
            if not equip:
                return False
            inv.equipment[equip] = stack
            return True

        moving = get_stack(src_type, src_slot)
        if not moving:
            return False

        target = get_stack(dst_type, dst_slot)

        # Clear source optimistically; will restore on failure
        set_stack(src_type, src_slot, None)

        if dst_type == "equip":
            equip_slot = parse_equip_slot(dst_slot)
            if not equip_slot:
                set_stack(src_type, src_slot, moving)
                return False

            # Validate equip
            old = inv.equip_item(equip_slot, moving)
            if old is None and moving.item and moving.item.equipment_slot != equip_slot:
                # Wrong slot - restore
                set_stack(src_type, src_slot, moving)
                return False

            # Swap back old equipped into source
            if old:
                set_stack(src_type, src_slot, old)
            return True

        # dst_type == main
        if not isinstance(dst_slot, int) or dst_slot < 0 or dst_slot >= inv.main_slots:
            set_stack(src_type, src_slot, moving)
            return False

        if target and target.item_id == moving.item_id and (moving.item and moving.item.stack_size > 1):
            overflow = target.add_quantity(moving.quantity)
            if overflow <= 0:
                # fully merged
                return True
            # partial merge -> return overflow to source
            moving.quantity = overflow
            set_stack(src_type, src_slot, moving)
            return True

        # Swap / move
        inv.set_item_stack(dst_slot, moving)
        if target:
            set_stack(src_type, src_slot, target)
        return True

    def handle_drop_item(self, player: Player, message: dict) -> bool:
        """Drop a stack from inventory/equipment onto the ground near the player."""
        from shared.items import EquipmentSlot
        from shared.constants import DROP_DISTANCE, DROP_VERTICAL_BONUS, DROP_VERTICAL_COMPONENT_THRESHOLD
        from server.entities import Item
        import random
        import math

        src_type = message.get("src_type")
        src_slot = message.get("src_slot")

        if src_type not in ("main", "equip"):
            return False

        inv = player.inventory
        stack = None
        equip_slot = None

        # Resolve the stack first WITHOUT mutating; if the drop location is blocked,
        # we do nothing and keep the item where it is.
        if src_type == "main":
            if not isinstance(src_slot, int) or src_slot < 0 or src_slot >= inv.main_slots:
                return
            stack = inv.get_item_stack(src_slot)
        else:
            if not isinstance(src_slot, str):
                return
            try:
                equip_slot = EquipmentSlot(src_slot)
            except Exception:
                return
            stack = inv.get_equipped(equip_slot)

        if not stack:
            return False

        facing_dx = message.get("facing_dx", 0.0)
        facing_dy = message.get("facing_dy", 0.0)
        try:
            facing_dx = float(facing_dx)
            facing_dy = float(facing_dy)
        except Exception:
            facing_dx, facing_dy = 0.0, 0.0

        length = math.hypot(facing_dx, facing_dy)
        if length > 1e-6:
            facing_dx /= length
            facing_dy /= length
            drop_distance = float(DROP_DISTANCE)
            if abs(facing_dy) >= float(DROP_VERTICAL_COMPONENT_THRESHOLD):
                drop_distance += float(DROP_VERTICAL_BONUS)
            drop_x = player.x + facing_dx * drop_distance
            drop_y = player.y + facing_dy * drop_distance
        else:
            # Fallback: small random offset
            drop_x = player.x + random.uniform(-20, 20)
            drop_y = player.y + random.uniform(-20, 20)

        # Validate drop target against water / walls (trees/rocks).
        if not self._is_drop_location_valid(drop_x, drop_y):
            return False

        # Now mutate inventory and spawn the item.
        if src_type == "main":
            inv.set_item_stack(src_slot, None)
        else:
            inv.unequip_item(equip_slot)

        drop = Item(0, item_id=stack.item_id, x=drop_x, y=drop_y)
        drop.set_quantity(max(1, int(getattr(stack, "quantity", 1) or 1)))
        drop_id = self.entity_manager.add_entity(drop)

        if self.database:
            try:
                self.entity_manager.save_entity(drop_id)
                self._save_player(player)
            except Exception:
                pass

        return True
    
    async def update_environmental_effects(self, dt: float):
        """
        Update environmental effects (radiation, cold, etc.) for all players.
        Each biome has different environmental hazards:
        - toxic_wasteland: Radiation increases near toxic water
        - frozen_world: Cold exposure on ice/frozen terrain
        - Future: magma_world: Heat/burn near lava
        
        🔥 OPTIMIZED: Use cached _players dict instead of clients.values()
        """
        from shared.constants import (
            ENVIRONMENT_DAMAGE_RATE, ENVIRONMENT_BUILDUP_RATE, 
            ENVIRONMENT_DECAY_RATE, MAX_RADIATION, MAX_COLD
        )
        
        # 🔥 OPTIMIZED: Use cached _players dict (avoids isinstance checks)
        for player in self.entity_manager._players.values():
            # Get environmental exposure at player position
            exposure = self.world.get_environmental_exposure(player.x, player.y)
            
            # Update radiation - INSTANT based on proximity to toxic water
            # Radiation doesn't "build up" - it's immediate environmental effect
            target_radiation = exposure["radiation"] * MAX_RADIATION
            player.radiation = int(target_radiation)  # Send as integer
            
            # Update cold - GRADUAL buildup (takes time to freeze/warm up)
            # Move towards target cold level
            target_cold = exposure["cold"] * MAX_COLD
            if player.cold < target_cold:
                # Warming up or getting colder
                player.cold = min(target_cold, 
                    player.cold + ENVIRONMENT_BUILDUP_RATE * dt)
            elif player.cold > target_cold:
                # Warming up (faster than cooling down)
                player.cold = max(target_cold, 
                    player.cold - ENVIRONMENT_DECAY_RATE * dt)
            
            # Apply damage from environmental effects
            # Damage scales with status level (at 100% status = full damage rate)
            rad_damage = (player.radiation / MAX_RADIATION) * ENVIRONMENT_DAMAGE_RATE * dt
            cold_damage = (player.cold / MAX_COLD) * ENVIRONMENT_DAMAGE_RATE * dt
            
            total_env_damage = rad_damage + cold_damage
            if total_env_damage > 0:
                player.health = max(0, player.health - total_env_damage)
                
                # Debug log for significant damage
                if total_env_damage > 0.1:
                    debug_print(DEBUG_STATS, 
                        f"☢️ {player.username}: Rad={player.radiation:.1f} Cold={player.cold:.1f} "
                        f"DMG={total_env_damage:.2f}/s HP={player.health:.1f}")
    
    async def load_chunks_around_players(self):
        """
        Load chunks around all players asynchronously.
        This doesn't block - chunks generate in background.
        """
        from shared.constants import TILE_SIZE, CHUNK_SIZE
        
        try:
            # Track which chunks should be loaded
            chunks_to_load = set()

            # 🔥 OPTIMIZED: Only consider connected players.
            # Scanning all entities each tick is unnecessary (and grows with world/resources).
            for player in self.clients.values():
                # Get player's chunk position
                player_chunk_x = int(player.x // (CHUNK_SIZE * TILE_SIZE))
                player_chunk_y = int(player.y // (CHUNK_SIZE * TILE_SIZE))
                
                # Load 3x3 chunks around player
                for dx in range(-1, 2):
                    for dy in range(-1, 2):
                        chunk_x = player_chunk_x + dx
                        chunk_y = player_chunk_y + dy
                        chunks_to_load.add((chunk_x, chunk_y))
            
            # Start async loading for all needed chunks (non-blocking)
            tasks = []
            for chunk_x, chunk_y in chunks_to_load:
                # Check if chunk is already loaded or generating
                key = (chunk_x, chunk_y)
                if key not in self.world.chunks and key not in self.world.generating_chunks:
                    # Start generation task (don't await - let it run in background)
                    task = asyncio.create_task(self.world.get_chunk_async(chunk_x, chunk_y))
                    tasks.append(task)
            
            # Don't wait for chunks to finish - they load in background!
            # Just log how many we started
            if tasks:
                debug_print(DEBUG_CHUNKS, f"🔄 Started loading {len(tasks)} chunks in background")
                
        except Exception as e:
            debug_print(DEBUG_CHUNKS, f"⚠️ Error in load_chunks_around_players: {e}")
    
    def _check_tree_collision(self, player: Player) -> bool:
        """Check if player collides with any tree - OPTIMIZED with spatial grid"""
        feet_x = player.x
        feet_y = player.y - 28
        
        # 🔥 OPTIMIZED: Use spatial grid to get only nearby trees (type 5)
        # This checks ~10-20 entities instead of ALL 1000+ entities!
        nearby_resources = self.entity_manager.get_entities_near(
            player.x, player.y, 
            radius=200,  # Only check within 200px
            entity_type=5  # Only resources (trees/rocks)
        )
        
        # Check collision with nearby resources
        for entity in nearby_resources:
            
            # Get resource variant ID (unified)
            variant_id = getattr(entity, 'variant_id', None)
            if variant_id is None:
                continue
            
            # Determine object type based on resource_type
            resource_type = entity.resource_type
            if resource_type == "tree":
                object_type = "trees"
                sprite_center = (96, 96)  # 192x192 tree sprites (expanded from 128x192)
                sprite_height = 192
            elif resource_type == "rock":
                object_type = "rocks"
                
                # Get current biome from world generator
                biome_id = self.world.generator.biome_id
                
                # Get rock dimensions from collision manager
                rock_width = 48
                rock_height = 48
                if (biome_id in self.collision_manager.dimensions and 
                    object_type in self.collision_manager.dimensions[biome_id] and
                    variant_id in self.collision_manager.dimensions[biome_id][object_type]):
                    rock_width, rock_height = self.collision_manager.dimensions[biome_id][object_type][variant_id]
                
                sprite_center = (rock_width / 2, rock_height / 2)
                sprite_height = rock_height
            else:
                continue  # Unknown resource type
            
            # Get current biome from world generator (for trees)
            if resource_type == "tree":
                biome_id = self.world.generator.biome_id
            
            # Check collision using CollisionManager
            if self.collision_manager.check_collision(
                feet_x, feet_y,
                biome_id, object_type,
                entity.x, entity.y,
                variant_id,
                sprite_center=sprite_center,
                sprite_height=sprite_height
            ):
                return True
        
        return False

    def _point_has_water(self, x: float, y: float) -> bool:
        """Return True if the point is on/over water (including transition tiles)."""
        tile_x = int(x // TILE_SIZE)
        tile_y = int(y // TILE_SIZE)

        chunk_x = tile_x // CHUNK_SIZE
        chunk_y = tile_y // CHUNK_SIZE

        chunk = self.world.get_chunk(chunk_x, chunk_y)
        if not chunk:
            # Chunk not ready -> treat as blocked so we don't drop into unknown.
            return True

        local_x = tile_x % CHUNK_SIZE
        local_y = tile_y % CHUNK_SIZE
        if not (0 <= local_x < CHUNK_SIZE and 0 <= local_y < CHUNK_SIZE):
            return True

        corners = chunk.get_corners(local_x, local_y)
        return any(corner == 0 for corner in corners)

    def _point_collides_with_resources(self, x: float, y: float) -> bool:
        """Return True if point is inside a collidable resource (tree/rock)."""
        nearby_resources = self.entity_manager.get_entities_near(
            x, y,
            radius=200,
            entity_type=5  # resources (trees/rocks)
        )

        biome_id = self.world.generator.biome_id

        for entity in nearby_resources:
            variant_id = getattr(entity, 'variant_id', None)
            if variant_id is None:
                continue

            resource_type = getattr(entity, 'resource_type', None)
            if resource_type == "tree":
                object_type = "trees"
                sprite_center = (96, 96)  # 192x192 tree sprites (expanded from 128x192)
                sprite_height = 192
            elif resource_type == "rock":
                object_type = "rocks"

                rock_width = 48
                rock_height = 48
                if (biome_id in self.collision_manager.dimensions and
                    object_type in self.collision_manager.dimensions[biome_id] and
                    variant_id in self.collision_manager.dimensions[biome_id][object_type]):
                    rock_width, rock_height = self.collision_manager.dimensions[biome_id][object_type][variant_id]

                sprite_center = (rock_width / 2, rock_height / 2)
                sprite_height = rock_height
            else:
                continue

            if self.collision_manager.check_collision(
                x, y,
                biome_id, object_type,
                entity.x, entity.y,
                variant_id,
                sprite_center=sprite_center,
                sprite_height=sprite_height
            ):
                return True

        return False

    def _is_drop_location_valid(self, x: float, y: float) -> bool:
        """Validate that a drop location is reachable (not water, not inside walls)."""
        if self._point_has_water(x, y):
            return False
        if self._point_collides_with_resources(x, y):
            return False
        return True
    
    def check_player_collision_after_move(self, player: Player, dt: float):
        """Check player's position after movement and resolve collisions.

        Simple mode (NO sliding): if the post-move position collides, revert fully.
        This matches the "stop hard" feel and avoids sliding along edges.
        """

        def _point_has_water_allow_unknown(px: float, py: float) -> bool:
            """Return True if point is on water; if chunk not loaded, allow (False)."""
            tile_x = int(px // TILE_SIZE)
            tile_y = int(py // TILE_SIZE)
            chunk_x = tile_x // CHUNK_SIZE
            chunk_y = tile_y // CHUNK_SIZE
            chunk = self.world.get_chunk(chunk_x, chunk_y)
            if not chunk:
                return False
            local_x = tile_x % CHUNK_SIZE
            local_y = tile_y % CHUNK_SIZE
            if not (0 <= local_x < CHUNK_SIZE and 0 <= local_y < CHUNK_SIZE):
                return False
            corners = chunk.get_corners(local_x, local_y)
            return any(corner == 0 for corner in corners)

        def _feet_collides(world_x: float, world_y: float) -> bool:
            """Collision test at player's feet point for a hypothetical player center (world_x, world_y)."""
            feet_x = world_x
            feet_y = world_y - 28
            if _point_has_water_allow_unknown(feet_x, feet_y):
                return True
            # Resources (trees/rocks) collision uses the same feet point.
            if self._point_collides_with_resources(feet_x, feet_y):
                return True
            return False

        dx = player.vx * dt
        dy = player.vy * dt

        # Reconstruct previous position before this tick's integration.
        x = player.x - dx
        y = player.y - dy

        # No sliding, but we must substep to avoid diagonal tunneling.
        step_px = 1.0
        max_delta = max(abs(dx), abs(dy))
        steps = int(max_delta // step_px)
        if (max_delta - (steps * step_px)) > 1e-9:
            steps += 1
        steps = max(1, min(steps, 20))

        step_dx = dx / steps
        step_dy = dy / steps

        for _ in range(steps):
            cand_x = x + step_dx
            cand_y = y + step_dy
            if _feet_collides(cand_x, cand_y):
                break
            x = cand_x
            y = cand_y

        player.x = x
        player.y = y
    
    async def broadcast(self, message: dict, exclude: asyncio.StreamWriter = None):
        """Broadcast message to all clients"""
        packed = msgpack.packb(message, use_bin_type=True)

        # IMPORTANT: We await drain() which yields control; self.clients can change.
        # Snapshot keys to avoid "dictionary changed size during iteration".
        for writer in list(self.clients.keys()):
            if writer != exclude:
                try:
                    writer.write(packed)
                    await writer.drain()
                except Exception:
                    pass
    
    async def game_loop(self):
        """Main game loop"""
        debug_print(DEBUG_INIT, "🎮 Game loop started")
        
        last_time = time.time()
        tick_count = 0
        
        while self.running:
            current_time = time.time()
            dt = current_time - last_time

            # No connected players -> freeze simulation (Minecraft-like idle).
            # This stops time progression, entity simulation, chunk loading and autosave.
            if not self.clients:
                last_time = current_time
                self.last_save_time = current_time
                tick_count = 0
                await asyncio.sleep(max(self.IDLE_SLEEP_SECONDS, self.tick_duration))
                continue
            
            if dt >= self.tick_duration:
                # Update game state
                self.entity_manager.update(dt)
                
                # Update day/night cycle
                self.world.update_time(dt)
                
                # Check collisions after movement
                for player in self.clients.values():
                    self.check_player_collision_after_move(player, dt)
                
                # Update harvesting
                await self.update_harvesting(dt)
                
                # Update environmental effects (radiation, cold, etc.)
                await self.update_environmental_effects(dt)
                
                # Load chunks around players (async - non-blocking)
                await self.load_chunks_around_players()
                
                # Send state updates to clients
                await self.send_state_updates()

                # Flush inventory updates after state_update so client-side pickup/harvest
                # animations can correlate world state with inventory changes.
                await self.flush_inventory_updates()
                
                last_time = current_time
                tick_count += 1
                
                # Debug stats every 5 seconds
                if current_time - self.last_debug_time >= self.debug_interval:
                    self._log_server_stats(tick_count, current_time - self.last_debug_time)
                    tick_count = 0
                    self.last_debug_time = current_time
                
                # Auto-save every 60 seconds
                if current_time - self.last_save_time >= self.AUTO_SAVE_INTERVAL:
                    await self.save_world(force=False)
                    self.last_save_time = current_time
            
            # 🔥 OPTIMIZED: Sleep exactly until next tick (precise timing!)
            # Instead of fixed 1ms, calculate remaining time in tick
            elapsed = time.time() - current_time
            sleep_time = max(0.001, self.tick_duration - elapsed)
            await asyncio.sleep(sleep_time)
    
    def _log_server_stats(self, ticks: int, elapsed: float):
        """Log server statistics"""
        if not DEBUG_STATS:
            return
            
        actual_tick_rate = ticks / elapsed if elapsed > 0 else 0
        
        console.print(f"\n📊 Server Stats:")
        console.print(f"   Tick Rate: {actual_tick_rate:.1f} TPS (target: {self.tick_rate})")
        console.print(f"   Connected Players: {len(self.clients)}")
        total_entities = len(self.entity_manager.entities)
        console.print(f"   Total Entities: {total_entities}")
        console.print(f"   Loaded Chunks: {len(self.world.chunks)}")
        console.print(f"   Generating Chunks: {len(self.world.generating_chunks)}")

        # Entity breakdown (helps spot CPU scaling issues). This runs only when DEBUG_STATS
        # is enabled (default is off), and only once per stats interval.
        try:
            from server.entities import Player, Item, ResourceNode

            players = len(self.entity_manager._players)
            static_entities = 0
            for entity in self.entity_manager.entities.values():
                if isinstance(entity, (Item, ResourceNode)):
                    static_entities += 1
            ticked_entities = max(0, total_entities - static_entities)
            console.print(f"   Entities (ticked): {ticked_entities} | static: {static_entities} | players: {players}")
            console.print(f"   Spatial Grid Cells: {len(self.entity_manager.spatial_grid)}")
        except Exception:
            pass
        
        # Per-player stats
        for writer, player in list(self.clients.items()):
            console.print(f"   - {player.username}: Pos({player.x:.0f}, {player.y:.0f})")
            stats = self._last_state_update_stats.get(writer)
            if stats:
                console.print(
                    f"     state_update: entities={stats.get('entities_sent', 0)} "
                    f"(nearby={stats.get('nearby_non_players', 0)}) bytes={stats.get('payload_bytes', 0)}"
                )
    
    async def send_state_updates(self):
        """Send world state to all clients (filtered per player) - OPTIMIZED"""
        if not self.clients:
            return

        # NOTE: Player.to_dict() includes heavy fields (inventory, harvest state).
        # Inventory is synchronized separately via inventory_update.

        # Precompute per-player dicts once per tick.
        player_ids = list(self.entity_manager._players.keys())
        own_by_id: dict[int, dict] = {}
        public_by_id: dict[int, dict] = {}
        for pid, p in self.entity_manager._players.items():
            d = p.to_dict()

            own = d.copy()
            own.pop("inventory", None)
            own_by_id[pid] = own

            pub = d.copy()
            pub.pop("inventory", None)
            pub.pop("harvesting_target", None)
            pub.pop("harvest_progress", None)
            public_by_id[pid] = pub

        # IMPORTANT: We await drain() which yields control; self.clients can change.
        # Snapshot items to avoid "dictionary changed size during iteration".
        for writer, player in list(self.clients.items()):
            try:
                # Build player list with per-recipient interest management.
                # Always include the recipient itself; include other players only when near.
                r2 = float(self.STATE_UPDATE_RADIUS * self.STATE_UPDATE_RADIUS)
                px = float(getattr(player, "x", 0.0) or 0.0)
                py = float(getattr(player, "y", 0.0) or 0.0)
                players_data = []
                for pid in player_ids:
                    if pid == player.id:
                        players_data.append(own_by_id[pid])
                        continue
                    op = self.entity_manager._players.get(pid)
                    if op is None:
                        continue
                    dx = float(getattr(op, "x", 0.0) or 0.0) - px
                    dy = float(getattr(op, "y", 0.0) or 0.0) - py
                    if (dx * dx + dy * dy) <= r2:
                        players_data.append(public_by_id[pid])

                # 🔥 OPTIMIZED: Build list directly (avoid .copy() + append)
                # Start with all players + add nearby entities in one pass
                nearby_entities = self.entity_manager.get_entities_near(
                    player.x, player.y,
                    radius=self.STATE_UPDATE_RADIUS,
                    entity_type=None  # All non-player entities
                )
                
                # 🔥 List comprehension for nearby non-players (faster than loop + append)
                nearby_data = [e.to_dict() for e in nearby_entities if not isinstance(e, Player)]
                
                # 🔥 Single concatenation
                entities_data = players_data + nearby_data
                
                state = {
                    "type": "state_update",
                    "entities": entities_data,
                    "game_time": self.world.game_time  # Minutes since midnight
                }
                # MessagePack is more efficient than JSON
                # Send raw packed objects (do NOT append extra separators like newlines)
                packed = msgpack.packb(state, use_bin_type=True)
                writer.write(packed)
                await writer.drain()

                if DEBUG_STATS:
                    # Capture telemetry for periodic stats without extra work.
                    self._last_state_update_stats[writer] = {
                        "entities_sent": len(entities_data),
                        "nearby_non_players": len(nearby_data),
                        "payload_bytes": len(packed),
                    }
            except Exception as e:
                debug_print(DEBUG_NETWORK, f"⚠️ Failed to send state update to player {player.username}: {e}")
                if DEBUG_STATS:
                    self._last_state_update_stats.pop(writer, None)

    def _mark_inventory_dirty(self, player_id: int):
        if isinstance(player_id, int) and player_id > 0:
            self._dirty_inventory_player_ids.add(player_id)

    async def _send_inventory_update(self, writer: asyncio.StreamWriter, player: Player, *, force: bool = False):
        inv_msg = {
            "type": "inventory_update",
            "inventory": player.inventory.to_dict(),
        }
        packed = msgpack.packb(inv_msg, use_bin_type=True)
        pid = int(getattr(player, "id", 0) or 0)

        if not force:
            last = self._last_inventory_update_by_player_id.get(pid)
            if last == packed:
                return

        self._last_inventory_update_by_player_id[pid] = packed
        writer.write(packed)
        await writer.drain()

    async def flush_inventory_updates(self):
        if not self._dirty_inventory_player_ids:
            return

        # Snapshot to avoid mutation during await.
        dirty_ids = list(self._dirty_inventory_player_ids)
        for pid in dirty_ids:
            writer = self._writer_by_player_id.get(pid)
            player = self.entity_manager._players.get(pid)
            if not writer or not player:
                self._dirty_inventory_player_ids.discard(pid)
                continue
            try:
                await self._send_inventory_update(writer, player)
            except Exception:
                pass
            finally:
                self._dirty_inventory_player_ids.discard(pid)
    
    async def save_world(self, *, force: bool = False, allow_without_players: bool = False):
        """Save world state to database (runs in thread pool to avoid blocking)"""
        if not force and not allow_without_players and not self.clients:
            return

        debug_print(DEBUG_SAVES, "💾 Auto-saving world...")
        
        # Run all DB operations in thread pool to avoid blocking event loop
        loop = asyncio.get_event_loop()
        
        try:
            await loop.run_in_executor(None, self._save_world_sync, force, allow_without_players)
            debug_print(DEBUG_SAVES, "✅ World saved!")
        except Exception as e:
            console.print(f"⚠️ Error saving world: {e}")
    
    def _save_world_sync(self, force: bool = False, allow_without_players: bool = False):
        """Synchronous save operations (called from thread pool)"""
        if not force and not allow_without_players and not self.clients:
            return

        # Save world metadata (update last_save timestamp + game_time)
        # Commit will be done once at the end via save_all() for batched writes.
        self.database.save_world_metadata(self.world.seed, game_time=self.world.game_time, commit=False)

        # Save dirty chunks/entities (force=True persists everything)
        self.world.save_all_chunks(force=force)
        self.entity_manager.save_all_entities(force=force)

        # Save all connected players (positions/inventory are game state too)
        # NOTE: This runs in a worker thread. Snapshot to avoid races with
        # connect/disconnect mutating self.clients in the event loop.
        for player in list(self.clients.values()):
            self._save_player(player)

        # Force commit (required for players + batched chunk/entity writes)
        self.database.save_all()

        # Cleanup distant chunks (free memory)
        self._cleanup_distant_chunks()
    
    def _save_player(self, player: Player):
        """Save player data to database"""
        if not self.database:
            return
        
        player_data = {
            "health": player.health,
            "hunger": player.hunger,
            "radiation": player.radiation,
            "cold": player.cold,
            "inventory": player.inventory.to_dict(),
            "harvesting_target": player.harvesting_target,
            "harvest_progress": player.harvest_progress,
        }
        
        self.database.save_player(
            player.id,
            player.username,
            player.x,
            player.y,
            player_data
        )
    
    def _cleanup_distant_chunks(self):
        """Unload chunks far from all players (already saved to DB)"""
        if not self.clients:
            return
        
        # Collect all chunks needed by any player (using set to avoid duplicates)
        needed_chunks: Set[Tuple[int, int]] = set()
        
        for player in list(self.clients.values()):
            # Player's chunk position
            player_chunk_x = int(player.x // (CHUNK_SIZE * TILE_SIZE))
            player_chunk_y = int(player.y // (CHUNK_SIZE * TILE_SIZE))
            
            # Add chunks within CHUNK_KEEP_RADIUS
            for dx in range(-self.CHUNK_KEEP_RADIUS, self.CHUNK_KEEP_RADIUS + 1):
                for dy in range(-self.CHUNK_KEEP_RADIUS, self.CHUNK_KEEP_RADIUS + 1):
                    chunk_x = player_chunk_x + dx
                    chunk_y = player_chunk_y + dy
                    needed_chunks.add((chunk_x, chunk_y))
        
        # Find chunks to unload (loaded but not needed by anyone)
        loaded_chunks = set(self.world.chunks.keys())
        chunks_to_unload = loaded_chunks - needed_chunks
        
        if chunks_to_unload:
            # NOTE: Do NOT unload entities here.
            # We currently load entities once on server startup (load_entities_from_db)
            # and do not stream them per-chunk on demand. If we removed entities when
            # unloading chunks, they'd disappear for the rest of the session.
            # (Worse: EntityManager.remove_entity() also deletes from DB.)

            # Remove chunks from memory (already saved to DB)
            for chunk_key in chunks_to_unload:
                if chunk_key in self.world.chunks:
                    del self.world.chunks[chunk_key]
            
            debug_print(DEBUG_CHUNKS, f"🧹 Unloaded {len(chunks_to_unload)} distant chunks from memory (keeping {len(needed_chunks)} near players)")
    
    def stop(self):
        """Stop the server and save world"""
        if not self.running:
            return  # Already stopped
        
        debug_print(DEBUG_INIT, "🛑 Stopping server...")
        self.running = False
        
        # Save before closing
        debug_print(DEBUG_SAVES, "💾 Saving world before shutdown...")
        try:
            # Batch all writes into a single transaction/commit.
            self.database.save_world_metadata(self.world.seed, game_time=self.world.game_time, commit=False)

            # On shutdown, prefer force=True to reduce risk of losing data due to
            # missed dirty flags.
            self.world.save_all_chunks(force=True)
            self.entity_manager.save_all_entities(force=True)
            
            # Save all connected players
            for player in list(self.clients.values()):
                self._save_player(player)

            # Ensure everything is committed before closing.
            self.database.save_all()
            
            debug_print(DEBUG_SAVES, "✅ World saved successfully!")
        except Exception as e:
            console.print(f"⚠️ Error saving world on shutdown: {e}")
        
        # Close database connection
        try:
            self.database.close()
            debug_print(DEBUG_INIT, "✅ Database closed")
        except Exception as e:
            debug_print(DEBUG_INIT, f"⚠️ Error closing database: {e}")


async def main():
    """Main entry point"""
    import argparse
    
    parser = argparse.ArgumentParser(description="STARWASTE Server")
    parser.add_argument("--host", default="localhost", help="Host address")
    parser.add_argument("--port", type=int, default=DEFAULT_PORT, help="Port number")
    parser.add_argument("--seed", help="World seed (only used for new worlds)")
    parser.add_argument("--new-world", action="store_true", help="Create new world (delete existing save)")
    
    args = parser.parse_args()
    
    server = GameServer(host=args.host, port=args.port, seed=args.seed, new_world=args.new_world)
    
    # Setup signal handlers for graceful shutdown
    shutdown_event = asyncio.Event()
    
    def signal_handler(sig, frame):
        """Handle shutdown signals (cross-platform)"""
        debug_print(DEBUG_INIT, "\n⚠️ Shutdown signal received")
        shutdown_event.set()
    
    # Register signal handlers (works on both Unix and Windows)
    # SIGINT is Ctrl+C, SIGTERM is kill command
    signal.signal(signal.SIGINT, signal_handler)
    signal.signal(signal.SIGTERM, signal_handler)
    
    try:
        # Start server in background task
        server_task = asyncio.create_task(server.start())
        
        # Wait for either server to finish or shutdown signal
        done, pending = await asyncio.wait(
            [server_task, asyncio.create_task(shutdown_event.wait())],
            return_when=asyncio.FIRST_COMPLETED
        )

        # If the server task finished with an exception, retrieve it so asyncio
        # doesn't report "Task exception was never retrieved" and so the error
        # is visible in a controlled way.
        if server_task in done:
            try:
                server_task.result()
            except Exception as e:
                console.print(f"\n⚠️ Server task ended with error: {e}")
                shutdown_event.set()
        
        # Cancel pending tasks
        for task in pending:
            task.cancel()
            try:
                await task
            except asyncio.CancelledError:
                pass
                
    except KeyboardInterrupt:
        debug_print(DEBUG_INIT, "\n⚠️ Interrupted by user (Ctrl+C)")
    except Exception as e:
        console.print(f"\n❌ Server error: {e}")  # Always show critical errors
        import traceback
        traceback.print_exc()
    finally:
        # Ensure world is saved on any exit
        server.stop()


if __name__ == "__main__":
    asyncio.run(main())
