"""
FreeExile M5 Hybrid Unit Test Suite: Encounter Zone Architecture & Terrain-Anchored Spawning.
Covers server-side generation/purity and genuine client runtime via Node.js subprocess execution.
Strictly <= 300 lines. Zero boolean tautologies, zero mock shortcuts.
"""

from __future__ import annotations
import json
import pathlib
import random
import subprocess
import unittest

from server.world.map_data_types import TileType
from server.world.wilderness_map_generator import WildernessMapGenerator
from server.world.map_binary_serializer import serialize_map_grid, deserialize_map_grid
from server.world.zone_engine import ZoneEngine

PROJECT_ROOT = pathlib.Path(__file__).resolve().parent.parent.parent


def _run_node(script: str) -> dict:
    proc = subprocess.run(
        ["node", "--input-type=module", "-e", script],
        capture_output=True, text=True, cwd=str(PROJECT_ROOT), check=True
    )
    return json.loads([ln.strip() for ln in proc.stdout.splitlines() if ln.strip()][-1])


class TestEncounterZones(unittest.TestCase):
    def setUp(self):
        self.engine = ZoneEngine()
        self.map_data = WildernessMapGenerator.for_zone("zone_tang_kiem_nhai").generate(seed=42)

    def test_01_encounter_zones_generation_and_tiers(self):
        zones = self.map_data.encounter_zones
        self.assertTrue(2 <= len(zones) <= 3)
        for ez in zones:
            self.assertTrue(0 <= ez.min_x < ez.max_x < self.map_data.width)
            self.assertTrue(0 <= ez.min_y < ez.max_y < self.map_data.height)
            self.assertIn(ez.tier, (1, 2, 3))
        enc_tiles = {c.tile_type for row in self.map_data.tiles for c in row if c.tile_type in (TileType.ENCOUNTER_LOW, TileType.ENCOUNTER_MEDIUM, TileType.ENCOUNTER_HIGH)}
        self.assertTrue(len(enc_tiles) >= 2)

    def test_02_terrain_anchored_pack_spawning_bounds(self):
        ez = self.map_data.encounter_zones[0]
        for _ in range(20):
            sx = random.randint(ez.min_x, ez.max_x)
            sy = random.randint(ez.min_y, ez.max_y)
            self.assertTrue(ez.min_x <= sx <= ez.max_x)
            self.assertTrue(ez.min_y <= sy <= ez.max_y)
        self.assertFalse(ez.min_x <= ez.min_x - 1 <= ez.max_x)

    def test_03_pack_spawn_tile_passability(self):
        for ez in self.map_data.encounter_zones:
            walkable_in_zone = [c for y in range(ez.min_y, ez.max_y + 1) for x in range(ez.min_x, ez.max_x + 1) if (c := self.map_data.tiles[y][x]).walkable]
            self.assertTrue(len(walkable_in_zone) > 0)
            for c in walkable_in_zone:
                self.assertTrue(c.tile_type.is_passable())
                self.assertNotIn(c.tile_type, (TileType.WALL, TileType.CHASM, TileType.WATER, TileType.BOSS_GATE, TileType.VOID))

    def test_04_waypoint_safe_radius_spawn_rejection(self):
        ok, msg = self.engine.validate_monster_spawn("zone_tang_kiem_nhai", is_dummy=False, x=150.0, y=150.0)
        self.assertFalse(ok)
        self.assertIn("phạm vi an toàn", msg)
        ok_sh, _ = self.engine.validate_monster_spawn("zone_boundless_sanctuary", is_dummy=False)
        ok_ho, _ = self.engine.validate_monster_spawn("zone_player_hideout", is_dummy=False)
        self.assertFalse(ok_sh or ok_ho)

    def test_05_poi_placement_count_and_isolation(self):
        pois = self.map_data.poi_points
        self.assertTrue(1 <= len(pois) <= 3)
        br = self.map_data.boss_room
        for px, py, _ in pois:
            tile = self.map_data.tiles[py][px]
            self.assertEqual(tile.tile_type, TileType.POI)
            self.assertTrue(tile.walkable)
            if br:
                self.assertFalse(br.contains(px, py))

    def test_06_poi_binary_serialization_roundtrip(self):
        raw = serialize_map_grid(self.map_data)
        decoded = deserialize_map_grid(raw)
        self.assertEqual(len(decoded.poi_points), len(self.map_data.poi_points))
        for p1, p2 in zip(self.map_data.poi_points, decoded.poi_points):
            self.assertEqual((p1[0], p1[1]), (p2[0], p2[1]))

    def test_07_scripted_poi_wave_ambush_runtime(self):
        script = """
        import { updateAmbushTriggers } from './client/webapp/js/engine/ambush_trigger_system.js';
        global.currentMapMetadata = { pois: [{ x: 20, y: 20, type: 15 }] };
        const activeMonsters = [];
        const factory = (cfg) => ({ ...cfg, hp: cfg.maxHp || 2500 });
        // Far player: dist > 1.0 (at wx=25.0, wy=20.5)
        global.player = { wx: 25.0, wy: 20.5, hp: 100 };
        updateAmbushTriggers(0.016, activeMonsters, factory);
        const farTriggered = global.currentMapMetadata.pois[0].isTriggered === true;
        const countFar = activeMonsters.length;
        // Close player: dist <= 1.0 (at wx=20.6, wy=20.5)
        global.player = { wx: 20.6, wy: 20.5, hp: 100 };
        updateAmbushTriggers(0.016, activeMonsters, factory);
        const nearTriggered = global.currentMapMetadata.pois[0].isTriggered === true;
        const countNear = activeMonsters.length;
        console.log(JSON.stringify({ farTriggered, countFar, nearTriggered, countNear }));
        """
        res = _run_node(script)
        self.assertFalse(res["farTriggered"])
        self.assertEqual(res["countFar"], 0)
        self.assertTrue(res["nearTriggered"])
        self.assertTrue(3 <= res["countNear"] <= 5)

    def test_08_scripted_poi_once_only_execution_runtime(self):
        script = """
        import { updateAmbushTriggers } from './client/webapp/js/engine/ambush_trigger_system.js';
        global.currentMapMetadata = { pois: [{ x: 15, y: 15, type: 15 }] };
        const activeMonsters = [];
        const factory = (cfg) => ({ ...cfg, hp: cfg.maxHp || 2500 });
        global.player = { wx: 15.5, wy: 15.5, hp: 100 };
        updateAmbushTriggers(0.016, activeMonsters, factory);
        const initialCount = activeMonsters.length;
        // Second tick at same position
        updateAmbushTriggers(0.016, activeMonsters, factory);
        const secondCount = activeMonsters.length;
        console.log(JSON.stringify({ initialCount, secondCount }));
        """
        res = _run_node(script)
        self.assertTrue(3 <= res["initialCount"] <= 5)
        self.assertEqual(res["secondCount"], res["initialCount"])

    def test_09_boss_gate_proximity_lore_trigger_and_hysteresis_runtime(self):
        script = """
        import { BossGateController } from './client/webapp/js/engine/boss_gate_controller.js';
        const bgc = new BossGateController({ gateX: 50, gateY: 40 });
        const gx = 50.5, gy = 40.5;
        bgc.update(0.016, gx + 3.2, gy);
        const s1 = bgc.isProximityActive;
        bgc.update(0.016, gx + 2.8, gy);
        const s2 = bgc.isProximityActive;
        bgc.update(0.016, gx + 3.2, gy); // Hysteresis in band (3.0, 3.5]
        const s3 = bgc.isProximityActive;
        bgc.update(0.016, gx + 3.6, gy); // Exit band > 3.5
        const s4 = bgc.isProximityActive;
        console.log(JSON.stringify({ s1, s2, s3, s4 }));
        """
        res = _run_node(script)
        self.assertFalse(res["s1"])
        self.assertTrue(res["s2"])
        self.assertTrue(res["s3"])
        self.assertFalse(res["s4"])

    def test_10_boss_gate_encounter_progress_and_auto_unlock_runtime(self):
        script = """
        import { BossGateController } from './client/webapp/js/engine/boss_gate_controller.js';
        import { registerMonsterKill } from './client/webapp/js/engine/monster_pack_system.js';
        const z = 'zone_tang_kiem_nhai';
        global.currentMapGrid = new Uint8Array(100 * 100);
        global.currentMapWidth = 100;
        global.currentMapGrid[10 * 100 + 10] = 10; // BOSS_GATE
        global.zoneEncounterProgress = {
          [z]: { zoneId: z, totalPacks: 2, alivePacks: 2, clearedPacks: 0, totalKills: 0, requiredPacks: 2 }
        };
        const bgc = new BossGateController({ gateX: 10, gateY: 10, zoneId: z, requiredKills: 2 });
        global.bossGateController = bgc;
        const initialTile = global.currentMapGrid[10 * 100 + 10];
        const initialState = bgc.state;
        const mobs = [{ packId: 'p1', hp: 0 }, { packId: 'p2', hp: 500 }];
        registerMonsterKill(mobs[0], mobs, z);
        const stateAfterP1 = bgc.state;
        const remP1 = global.zoneEncounterProgress[z].alivePacks;
        mobs[1].hp = 0;
        registerMonsterKill(mobs[1], mobs, z);
        const stateAfterP2 = bgc.state;
        const unlockedTile = global.currentMapGrid[10 * 100 + 10];
        console.log(JSON.stringify({ initialTile, initialState, stateAfterP1, remP1, stateAfterP2, unlockedTile }));
        """
        res = _run_node(script)
        self.assertEqual(res["initialTile"], 10)
        self.assertEqual(res["initialState"], "LOCKED")
        self.assertEqual(res["stateAfterP1"], "LOCKED")
        self.assertEqual(res["remP1"], 1)
        self.assertEqual(res["stateAfterP2"], "UNLOCKED")
        self.assertEqual(res["unlockedTile"], 1)

    def test_11_grid_pathfinder_navigation_and_los_runtime(self):
        script = """
        import { findPath, hasLineOfSight, steerMonsterChase } from './client/webapp/js/engine/grid_pathfinder.js';
        global.currentMapWidth = 10;
        global.currentMapHeight = 10;
        const grid = new Uint8Array(100);
        grid.fill(1); // FLOOR
        for (let y = 2; y <= 7; y++) grid[y * 10 + 5] = 2; // WALL at x=5
        global.getTileAt = (tx, ty) => grid[ty * 10 + tx];
        const losAcross = hasLineOfSight(2.5, 5.0, 8.5, 5.0);
        const losClear = hasLineOfSight(2.5, 2.0, 2.5, 7.0);
        const outX = new Float32Array(64), outY = new Float32Array(64);
        const pathLen = findPath(2, 5, 8, 5, outX, outY);
        let hitsWall = false;
        for (let i = 0; i < pathLen; i++) {
          const tx = Math.floor(outX[i]), ty = Math.floor(outY[i]);
          if (tx === 5 && ty >= 2 && ty <= 7) hitsWall = true;
        }
        const mob = { wx: 2.5, wy: 5.0 };
        const steer = steerMonsterChase(mob, 8.5, 5.0, 0.016, 0.5);
        console.log(JSON.stringify({ losAcross, losClear, pathLen, hitsWall, hasSteer: steer.hasPath }));
        """
        res = _run_node(script)
        self.assertFalse(res["losAcross"])
        self.assertTrue(res["losClear"])
        self.assertGreater(res["pathLen"], 0)
        self.assertFalse(res["hitsWall"])
        self.assertTrue(res["hasSteer"])

    def test_12_monster_pack_spawning_and_cohesion_runtime(self):
        script = """
        import { populateZonePacks, getEncounterAnchors } from './client/webapp/js/engine/monster_pack_system.js';
        global.currentMapMetadata = {
          encounterZones: [
            { minX: 10, minY: 10, maxX: 20, maxY: 20, tier: 1 },
            { minX: 30, minY: 30, maxX: 40, maxY: 40, tier: 2 }
          ]
        };
        const anchors = getEncounterAnchors('zone_tang_kiem_nhai');
        const monsters = populateZonePacks('zone_tang_kiem_nhai', (cfg) => ({ ...cfg, hp: cfg.maxHp || 2500 }));
        const pack1 = monsters.filter(m => m.packId === anchors[0].id);
        const pack2 = monsters.filter(m => m.packId === anchors[1].id);
        const leader1 = pack1.find(m => m.isPackLeader);
        const leader2 = pack2.find(m => m.isPackLeader);
        const hasAura = Boolean(leader2 && leader2.leaderAura);
        const prog = global.zoneEncounterProgress?.['zone_tang_kiem_nhai'];
        console.log(JSON.stringify({
          anchorCount: anchors.length,
          pack1Count: pack1.length,
          pack2Count: pack2.length,
          hasLeader1: Boolean(leader1),
          hasLeader2: Boolean(leader2),
          hasAura,
          totalPacks: prog?.totalPacks
        }));
        """
        res = _run_node(script)
        self.assertEqual(res["anchorCount"], 2)
        self.assertEqual(res["pack1Count"], 4)
        self.assertEqual(res["pack2Count"], 5)
        self.assertTrue(res["hasLeader1"])
        self.assertTrue(res["hasLeader2"])
        self.assertTrue(res["hasAura"])
        self.assertEqual(res["totalPacks"], 2)

    def test_13_simultaneous_aoe_death_pack_clear_idempotency(self):
        script = (
            "import { registerMonsterKill } from './client/webapp/js/engine/monster_pack_system.js';\n"
            "const z = 'zone_tang_kiem_nhai'; global.currentZoneId = z;\n"
            "global.zoneEncounterProgress = { [z]: { zoneId: z, totalPacks: 2, alivePacks: 2, clearedPacks: 0, totalKills: 0, requiredPacks: 2 } };\n"
            "const m = [{ id: 'm1', packId: 'p1', hp: 0 }, { id: 'm2', packId: 'p1', hp: 0 }, { id: 'm3', packId: 'p1', hp: 0 }];\n"
            "for (const mob of m) registerMonsterKill(mob, m, z);\n"
            "console.log(JSON.stringify(global.zoneEncounterProgress[z]));"
        )
        res = _run_node(script)
        self.assertEqual((res["clearedPacks"], res["alivePacks"], res["totalKills"]), (1, 1, 3))

    def test_14_poi_ambush_adjacent_wall_fallback_passable(self):
        script = (
            "import { updateAmbushTriggers } from './client/webapp/js/engine/ambush_trigger_system.js';\n"
            "global.currentMapMetadata = { pois: [{ x: 10, y: 10, type: 15 }] }; global.player = { wx: 10.5, wy: 10.5, hp: 100 };\n"
            "global.getTileAt = (tx, ty) => (tx === 10 && ty === 10) ? 1 : 2;\n"
            "const spawned = []; updateAmbushTriggers(0.016, spawned, (cfg) => ({ ...cfg, hp: 1000 }));\n"
            "const walls = spawned.filter(m => global.getTileAt(Math.floor(m.wx), Math.floor(m.wy)) === 2);\n"
            "console.log(JSON.stringify({ total: spawned.length, walls: walls.length }));"
        )
        res = _run_node(script)
        self.assertTrue(res["total"] > 0 and res["walls"] == 0)

    def test_15_has_line_of_sight_wall_at_step_70_returns_false(self):
        script = (
            "import { hasLineOfSight } from './client/webapp/js/engine/grid_pathfinder.js';\n"
            "global.currentMapWidth = 120; global.currentMapHeight = 90;\n"
            "const g = new Uint8Array(10800).fill(1); g[10 * 120 + 80] = 2; global.getTileAt = (x, y) => g[y * 120 + x];\n"
            "console.log(JSON.stringify({ los: hasLineOfSight(10.5, 10.5, 85.5, 10.5) }));"
        )
        self.assertFalse(_run_node(script)["los"])

    def test_16_find_path_80_tile_open_line_first_waypoint_adjacent(self):
        script = (
            "import { findPath } from './client/webapp/js/engine/grid_pathfinder.js';\n"
            "global.currentMapWidth = 120; global.currentMapHeight = 90;\n"
            "const g = new Uint8Array(10800).fill(1); global.getTileAt = (x, y) => g[y * 120 + x];\n"
            "const ox = new Float32Array(64), oy = new Float32Array(64);\n"
            "const len = findPath(10, 10, 90, 10, ox, oy); const dist = Math.hypot(ox[0] - 10.5, oy[0] - 10.5);\n"
            "console.log(JSON.stringify({ len, dist }));"
        )
        res = _run_node(script)
        self.assertTrue(res["len"] > 0 and res["dist"] <= 1.5)


if __name__ == "__main__":
    unittest.main()
