"""Unit test suite for FreeExile Milestone M1: Procedural Tile Map Pipeline Server to Client."""

from __future__ import annotations
import math, shutil, subprocess
from typing import List, Tuple
import pytest

from server.world.map_data_types import TileType, TileCell, MapGridData, EncounterZone
from server.world.wilderness_map_generator import WildernessMapGenerator, ZONE_DEFAULT_SIZES
from server.world.map_binary_serializer import (
    serialize_map_grid,
    deserialize_map_grid,
    HEADER_MAGIC,
    HEADER_SIZE,
    BIOME_TO_CODE,
)
from server.world.procedural_map_engine import (
    ProceduralMapEngine,
    generate_wilderness_map,
    verify_path_connectivity,
    calculate_path_sinuosity,
)


class TestTileTypeEnumExpansion:
    """Verifies TileType codes 0-19 and behavior methods."""

    def test_all_twenty_tile_codes_registered(self) -> None:
        expected = {
            "VOID": 0, "FLOOR": 1, "WALL": 2, "DESTRUCTIBLE_BARRICADE": 3, "MUD_POOL": 4,
            "SPIKE_TRAP": 5, "CRUMBLED_DEBRIS": 6, "BONE_PILE": 7, "POISON_VENT": 8, "CHASM": 9,
            "BOSS_GATE": 10, "BOSS_ALTAR": 11, "RUNIC_FLOOR": 12, "PATH": 13, "DENSE_TERRAIN": 14,
            "POI": 15, "ENCOUNTER_LOW": 16, "ENCOUNTER_MEDIUM": 17, "ENCOUNTER_HIGH": 18, "WATER": 19,
        }
        for name, code in expected.items():
            assert TileType[name].value == code
        assert len(TileType) == 20

    def test_tile_passability_and_movement_cost(self) -> None:
        passable_types = [
            TileType.FLOOR, TileType.MUD_POOL, TileType.SPIKE_TRAP, TileType.PATH,
            TileType.DENSE_TERRAIN, TileType.POI, TileType.ENCOUNTER_LOW,
            TileType.ENCOUNTER_MEDIUM, TileType.ENCOUNTER_HIGH,
        ]
        for t in passable_types:
            assert t.is_passable() is True

        impassable_types = [
            TileType.VOID, TileType.WALL, TileType.DESTRUCTIBLE_BARRICADE,
            TileType.CHASM, TileType.BOSS_GATE, TileType.WATER,
        ]
        for t in impassable_types:
            assert t.is_passable() is False

        assert TileType.PATH.movement_cost() == 0.8
        assert TileType.DENSE_TERRAIN.movement_cost() == 1.43
        assert TileType.MUD_POOL.movement_cost() == 2.0
        assert TileType.FLOOR.movement_cost() == 1.0


class TestWildernessMapGenerator:
    """Verifies wilderness map layout, paths, encounter zones, and guarantees."""

    @pytest.mark.parametrize(
        "zone_id,expected_w,expected_h",
        [
            ("zone_tang_kiem_nhai", 60, 45), ("zone_ancient_sword_barrow", 68, 52),
            ("zone_boundless_sandstorm", 76, 58), ("zone_blood_scale_ruins", 84, 64),
            ("zone_five_elements_altar", 92, 70), ("zone_abyssal_ice_pond", 100, 76),
            ("zone_infinite_blood_rift", 108, 82), ("zone_purgatory_lava_cavern", 114, 86),
            ("zone_boundless_celestial_palace", 120, 90),
        ],
    )
    def test_canonical_wilderness_dimensions(self, zone_id: str, expected_w: int, expected_h: int) -> None:
        gen = WildernessMapGenerator.for_zone(zone_id)
        assert gen.width == expected_w and gen.height == expected_h
        map_data = gen.generate(seed=101)
        assert map_data.width == expected_w and map_data.height == expected_h
        assert len(map_data.tiles) == expected_h and len(map_data.tiles[0]) == expected_w

    def test_spawn_safe_area_radius_8_guarantee(self) -> None:
        gen = WildernessMapGenerator.for_zone("zone_tang_kiem_nhai")
        for seed in [1, 42, 100, 777, 99999]:
            map_data = gen.generate(seed=seed)
            sx, sy = map_data.spawn_point
            for y in range(map_data.height):
                for x in range(map_data.width):
                    if math.hypot(x - sx, y - sy) <= 8.0:
                        cell = map_data.tiles[y][x]
                        assert cell.walkable is True, f"Blocked cell ({x},{y}) inside spawn radius for seed {seed}"
                        assert cell.tile_type in (TileType.FLOOR, TileType.PATH), f"Non-floor tile {cell.tile_type} inside spawn radius"

    def test_guaranteed_sinuous_main_path(self) -> None:
        for seed in [1, 2, 42, 100, 12345]:
            gen = WildernessMapGenerator.for_zone("zone_tang_kiem_nhai")
            map_data = gen.generate(seed=seed)
            connected, path = verify_path_connectivity(map_data, map_data.spawn_point, map_data.boss_gate, allow_sealed_boss_gate=True)
            assert connected is True, f"Main path disconnected for seed {seed}"
            sinuosity = calculate_path_sinuosity(path, map_data.spawn_point, map_data.boss_gate)
            assert sinuosity >= 1.25, f"Path sinuosity {sinuosity} < 1.25 for seed {seed}"

    def test_encounter_zones_and_dead_ends(self) -> None:
        gen = WildernessMapGenerator.for_zone("zone_tang_kiem_nhai")
        map_data = gen.generate(seed=555)
        assert 2 <= len(map_data.encounter_zones) <= 3
        tiers = [e.tier for e in map_data.encounter_zones]
        assert 1 in tiers and 2 in tiers
        for enc in map_data.encounter_zones:
            assert 0 <= enc.min_x < enc.max_x < map_data.width
            assert 0 <= enc.min_y < enc.max_y < map_data.height

        assert 1 <= len(map_data.dead_end_points) <= 2
        for dx, dy in map_data.dead_end_points:
            assert map_data.tiles[dy][dx].walkable is True

    def test_boss_arena_perimeter_isolation(self) -> None:
        gen = WildernessMapGenerator.for_zone("zone_tang_kiem_nhai")
        map_data = gen.generate(seed=999)
        br = map_data.boss_room
        assert br is not None
        gate_count, wall_count = 0, 0
        for y in range(br.y, br.y + br.h):
            for x in range(br.x, br.x + br.w):
                if x == br.x or x == br.x + br.w - 1 or y == br.y or y == br.y + br.h - 1:
                    t = map_data.tiles[y][x].tile_type
                    if t == TileType.BOSS_GATE:
                        gate_count += 1
                    elif t == TileType.WALL:
                        wall_count += 1
        assert gate_count == 1
        assert wall_count == (br.w * 2 + br.h * 2 - 4) - 1

    def test_poi_placement_count(self) -> None:
        gen = WildernessMapGenerator.for_zone("zone_tang_kiem_nhai")
        map_data = gen.generate(seed=888)
        assert 1 <= len(map_data.poi_points) <= 3
        for px, py, _ in map_data.poi_points:
            assert map_data.tiles[py][px].tile_type == TileType.POI
            assert map_data.tiles[py][px].walkable is True


class TestMapBinarySerializer:
    """Verifies binary encoding, decoding, truncation detection, and unknown tile resilience."""

    def test_binary_roundtrip_integrity(self) -> None:
        gen = WildernessMapGenerator.for_zone("zone_tang_kiem_nhai")
        orig = gen.generate(seed=777)
        binary_data = serialize_map_grid(orig)

        assert binary_data[:2] == HEADER_MAGIC
        assert len(binary_data) >= HEADER_SIZE + orig.width * orig.height

        restored = deserialize_map_grid(binary_data)
        assert restored.width == orig.width
        assert restored.height == orig.height
        assert restored.spawn_point == orig.spawn_point
        assert restored.boss_gate == orig.boss_gate
        assert len(restored.encounter_zones) == len(orig.encounter_zones)
        assert len(restored.poi_points) == len(orig.poi_points)

        for y in range(orig.height):
            for x in range(orig.width):
                assert restored.tiles[y][x].tile_type == orig.tiles[y][x].tile_type

    def test_binary_header_error_handling(self) -> None:
        with pytest.raises(ValueError, match="Buffer too short"):
            deserialize_map_grid(b"SHORT")

        corrupt_magic = b"XX" + b"\x00" * 20
        with pytest.raises(ValueError, match="Invalid map magic"):
            deserialize_map_grid(corrupt_magic)

    def test_binary_truncation_across_all_sections(self) -> None:
        gen = WildernessMapGenerator.for_zone("zone_tang_kiem_nhai")
        valid_buf = serialize_map_grid(gen.generate(seed=42))
        total_len = len(valid_buf)
        for cut in [16, 20, 25, total_len - 1, total_len - 50]:
            with pytest.raises(ValueError, match="Buffer truncated"):
                deserialize_map_grid(valid_buf[:cut])

    def test_unknown_tile_value_fallback_to_wall(self) -> None:
        gen = WildernessMapGenerator.for_zone("zone_tang_kiem_nhai")
        raw = bytearray(serialize_map_grid(gen.generate(seed=7)))
        raw[-1] = 99  # Out of range tile code
        restored = deserialize_map_grid(bytes(raw))
        assert restored.tiles[-1][-1].tile_type == TileType.WALL

    def test_facade_and_to_binary_integration(self) -> None:
        map_data = generate_wilderness_map("zone_tang_kiem_nhai", seed=123)
        assert isinstance(map_data, MapGridData)
        b = map_data.to_binary()
        assert len(b) > 0
        decoded = deserialize_map_grid(b)
        assert decoded.width == map_data.width


class TestClientTileGridLoaderNodeIntegration:
    """Verifies client-side JS loader decodes binary stream, handles OOB/NaN queries, and in-place mutations."""

    @staticmethod
    def _run_node(script: str) -> subprocess.CompletedProcess[str]:
        return subprocess.run(
            ["node", "-e", script],
            capture_output=True,
            text=True,
            cwd=r"c:\Projects\FreeExile",
        )

    def test_node_tile_grid_loader_roundtrip(self, tmp_path) -> None:
        if not shutil.which("node"):
            pytest.skip("Node.js runtime not installed in environment")

        gen = WildernessMapGenerator.for_zone("zone_tang_kiem_nhai")
        map_data = gen.generate(seed=321)
        bin_path = tmp_path / "map_test.bin"
        bin_path.write_bytes(serialize_map_grid(map_data))
        bin_str = str(bin_path).replace("\\", "/")

        js_script = f"""
        const fs = require('fs');
        const {{ TileGridLoader }} = require('./client/webapp/js/engine/tile_grid_loader.js');
        const buf = fs.readFileSync('{bin_str}');
        const meta = TileGridLoader.loadBinaryMap(buf);
        if (!meta) process.exit(1);
        if (window.currentMapWidth !== {map_data.width}) process.exit(2);
        if (window.currentMapHeight !== {map_data.height}) process.exit(3);
        const spawnTile = window.getTileAt(meta.spawn.x, meta.spawn.y);
        if (spawnTile !== 1 && spawnTile !== 13) process.exit(4);
        if (window.getTileAt(-10, -10) !== 2) process.exit(5);
        process.exit(0);
        """
        res = self._run_node(js_script)
        assert res.returncode == 0, f"Node decoding failed: {res.stderr}"

    def test_node_loader_clean_import_without_window(self) -> None:
        if not shutil.which("node"):
            pytest.skip("Node.js runtime not installed in environment")
        script = """
        const { TileGridLoader, BIOME_CODES } = require('./client/webapp/js/engine/tile_grid_loader.js');
        if (!TileGridLoader || !BIOME_CODES) process.exit(1);
        """
        res = self._run_node(script)
        assert res.returncode == 0, f"Clean Node require failed (window ReferenceError): {res.stderr}"

    def test_node_get_tile_at_non_finite_and_oob(self) -> None:
        if not shutil.which("node"):
            pytest.skip("Node.js runtime not installed in environment")
        script = """
        const { TileGridLoader } = require('./client/webapp/js/engine/tile_grid_loader.js');
        TileGridLoader.loadFallbackGrid(60, 45, 1);
        const badCoords = [NaN, undefined, -1, 60, Infinity, -Infinity, "invalid"];
        for (const c of badCoords) {
            if (TileGridLoader.getTileAt(c, 0) !== 2 || TileGridLoader.getTileAt(0, c) !== 2) {
                console.error("Failed for coord:", c, "got:", TileGridLoader.getTileAt(c, 0));
                process.exit(1);
            }
        }
        if (TileGridLoader.getTileAt(NaN, NaN) !== 2) process.exit(2);
        if (TileGridLoader.getTileAt(0.9, 0.9) !== 1) process.exit(3);
        process.exit(0);
        """
        res = self._run_node(script)
        assert res.returncode == 0, f"getTileAt non-finite/OOB failed: {res.stderr}"

    def test_node_set_tile_at_mutation_and_guards(self) -> None:
        if not shutil.which("node"):
            pytest.skip("Node.js runtime not installed in environment")
        script = """
        const { TileGridLoader } = require('./client/webapp/js/engine/tile_grid_loader.js');
        TileGridLoader.loadFallbackGrid(10, 10, 1);
        if (!TileGridLoader.setTileAt(5, 5, 10) || TileGridLoader.getTileAt(5, 5) !== 10) process.exit(1);
        if (!TileGridLoader.setTileAt(5, 5, 1) || TileGridLoader.getTileAt(5, 5) !== 1) process.exit(2);
        for (const bad of [-1, 10, NaN, Infinity]) {
            if (TileGridLoader.setTileAt(bad, 0, 1) !== false) process.exit(3);
        }
        process.exit(0);
        """
        res = self._run_node(script)
        assert res.returncode == 0, f"setTileAt mutation or guard failed: {res.stderr}"

    def test_node_loader_truncated_and_corrupt_buffers(self, tmp_path) -> None:
        if not shutil.which("node"):
            pytest.skip("Node.js runtime not installed in environment")
        gen = WildernessMapGenerator.for_zone("zone_tang_kiem_nhai")
        bin_path = tmp_path / "valid.bin"
        bin_path.write_bytes(serialize_map_grid(gen.generate(seed=42)))
        bin_str = str(bin_path).replace("\\", "/")

        script = f"""
        const fs = require('fs');
        const {{ TileGridLoader }} = require('./client/webapp/js/engine/tile_grid_loader.js');
        if (TileGridLoader.loadBinaryMap(null) !== null) process.exit(1);
        if (TileGridLoader.loadBinaryMap(Buffer.alloc(0)) !== null) process.exit(2);
        if (TileGridLoader.loadBinaryMap(Buffer.alloc(15)) !== null) process.exit(3);

        const valid = fs.readFileSync('{bin_str}');
        const corrupt = Buffer.from(valid); corrupt[0] = 88; corrupt[1] = 88;
        if (TileGridLoader.loadBinaryMap(corrupt) !== null) process.exit(4);

        for (const cut of [16, 20, valid.length - 1]) {{
            if (TileGridLoader.loadBinaryMap(valid.subarray(0, cut)) !== null) process.exit(5);
        }}

        const fake = Buffer.alloc(16);
        fake.write("FE", 0, 2, 'ascii'); fake.writeUInt8(1, 2); fake.writeUInt8(1, 3);
        fake.writeUInt16LE(0, 4); fake.writeUInt16LE(0, 6); fake.writeUInt8(10, 12);
        if (TileGridLoader.loadBinaryMap(fake) !== null) process.exit(6);

        process.exit(0);
        """
        res = self._run_node(script)
        assert res.returncode == 0, f"Node buffer rejection failed: {res.stderr}"
