"""
Unit Test Suite for Fog of War Multi-State Engine & Minimap HUD (Milestone M4).
Genuine Node.js runtime tests: 3-state lifecycle, persistence, 30Hz throttling, anti-maphack.
"""

import json
import math
import subprocess
from pathlib import Path
import pytest

ROOT = Path(__file__).resolve().parent.parent.parent
WAR_FOG_JS = ROOT / "client" / "webapp" / "js" / "ui" / "war_fog.js"
WAR_FOG_RENDERER_JS = ROOT / "client" / "webapp" / "js" / "ui" / "war_fog_renderer.js"
MINIMAP_HUD_JS = ROOT / "client" / "webapp" / "js" / "ui" / "minimap_hud.js"
INDEX_HTML = ROOT / "client" / "webapp" / "index.html"
WORLD_RENDERER_JS = ROOT / "client" / "webapp" / "js" / "engine" / "world_renderer.js"
ENTITY_RENDERER_JS = ROOT / "client" / "webapp" / "js" / "engine" / "entity_renderer.js"
MONSTER_SYSTEM_JS = ROOT / "client" / "webapp" / "js" / "engine" / "monster_system.js"


class TestCodeHygieneAndExports:
    def test_line_count_caps(self):
        """Verifies strict line budgets for M4 files."""
        assert len(WAR_FOG_JS.read_text(encoding="utf-8").splitlines()) <= 300
        assert len(WAR_FOG_RENDERER_JS.read_text(encoding="utf-8").splitlines()) <= 250
        assert len(MINIMAP_HUD_JS.read_text(encoding="utf-8").splitlines()) <= 280
        assert len(INDEX_HTML.read_text(encoding="utf-8").splitlines()) <= 300

    def test_es_exports_and_elements_present(self):
        """Verifies required symbols and DOM bindings exist."""
        wf = WAR_FOG_JS.read_text(encoding="utf-8")
        for sym in ["FOG_STATE", "initFog", "getFogState", "setFogState", "updatePlayerVision",
                    "packFogBits", "unpackFogBits", "saveFog", "loadFog", "getStorageKey"]:
            assert sym in wf, f"Missing symbol {sym} in war_fog.js"
        assert "BỊ CHẶN BỞI RÀO CẢN" in wf, "Notice string must exist for modal test compatibility"

        html = INDEX_HTML.read_text(encoding="utf-8")
        assert 'id="minimap-container"' in html
        assert 'id="minimap-canvas"' in html
        assert 'src="js/ui/minimap_hud.js"' in html


class TestWarFogNodeRuntime:
    @classmethod
    def setup_class(cls):
        js_code = """
        const fs = require('fs');
        let code = fs.readFileSync('client/webapp/js/ui/war_fog.js', 'utf8')
          .replace(/import\\s+[^;]+;\\s*/g, '').replace(/export\\s+(const|let)\\s+/g, 'var ')
          .replace(/export\\s+function\\s+/g, 'function ').replace(/export\\s+\\{[^}]+\\};?\\s*/g, '')
          .replace(/export\\s+default\\s+[^;]+;?\\s*/g, '');

        const mockStorage = {};
        global.localStorage = {
          getItem: (k) => mockStorage[k] || null,
          setItem: (k, v) => { mockStorage[k] = v; },
          removeItem: (k) => { delete mockStorage[k]; }
        };
        global.document = { getElementById: () => null, addEventListener: () => {} };
        global.window = {};
        const GRID_SIZE = 32, TILE_PX = 15, BIOMES = [{ id: 'TEST', name: 'Test' }];
        function generateMazeData() { return { biomeIdx: 0, mapTiles: [], fogMatrix: [], barricades: [], bossGate: null, bossRoom: null, playerPos: {x:0, y:0}, bossPos: {x:10, y:10}, simulatedPath: [] }; }
        function renderProceduralMap() {}

        eval(code);

        const results = {};
        initFog(60, 45, 'zone_tang_kiem_nhai', 101);
        results.init = { w: mapW, h: mapH, at00: getFogState(0, 0), atNeg: getFogState(-1, 0), atOob: getFogState(60, 45) };

        updatePlayerVision(10, 10, 8);
        const r10_10 = getFogState(10, 10), rEdge = getFogState(10 + 8, 10), rOut = getFogState(10 + 9, 10);
        updatePlayerVision(25, 25, 8);
        results.vision = { cBefore: r10_10, edgeBefore: rEdge, outBefore: rOut, cDecayed: getFogState(10, 10), cNew: getFogState(25, 25) };

        initFog(120, 90, 'zone_boundless_celestial_palace', 777);
        for (let i = 0; i < 120 * 90; i++) {
          if ((i * 37 + 13) % 7 === 0) fogGrid[i] = (i % 2 === 0) ? 1 : 2;
        }
        const storageKey = getStorageKey('zone_boundless_celestial_palace', 777);
        const packed = packFogBits(fogGrid, 120, 90);
        saveFog('zone_boundless_celestial_palace', 777);
        const savedRaw = localStorage.getItem(storageKey);
        const fresh = new Uint8Array(120 * 90);
        const unpackOk = unpackFogBits(savedRaw, fresh, 120, 90);
        let bitMatches = 0;
        for (let i = 0; i < 120 * 90; i++) {
          if ((fogGrid[i] > 0) === (fresh[i] > 0)) bitMatches++;
        }
        results.persist = { storageKey, packedBytesKb: (packed.length / 1024), unpackOk, total: 120 * 90, bitMatches };

        console.log(JSON.stringify(results));
        """
        proc = subprocess.run(["node", "-e", js_code], capture_output=True, text=True, cwd=str(ROOT), check=True)
        cls.node_res = json.loads(proc.stdout)

    def test_node_init_and_bounds(self):
        st = self.node_res["init"]
        assert st["w"] == 60 and st["h"] == 45
        assert st["at00"] == 0 and st["atNeg"] == 0 and st["atOob"] == 0

    def test_node_3state_vision_reveal_and_decay(self):
        vis = self.node_res["vision"]
        assert vis["cBefore"] == 2 and vis["edgeBefore"] == 2 and vis["outBefore"] == 0
        assert vis["cDecayed"] == 1, "Tiles outside current radius must decay to EXPLORED_FOGGED (1)"
        assert vis["cNew"] == 2

    def test_node_compact_bitpacking(self):
        p = self.node_res["persist"]
        assert p["storageKey"] == "fe_fog_zone_boundless_celestial_palace_777"
        assert p["unpackOk"] is True
        assert p["packedBytesKb"] < 2.0, f"Packed size {p['packedBytesKb']} KB exceeds 2.0 KB"
        assert p["bitMatches"] == p["total"], "100% bit preservation required"


class TestMinimapProjectionAndGeometry:
    def test_aspect_ratio_preservation_and_centering(self):
        """Aspect ratio preservation with padding 2 on 120x80 canvas."""
        # 60x45 in (116 x 76)
        scale = min(116.0 / 60.0, 76.0 / 45.0)
        assert math.isclose(scale, 76.0 / 45.0, rel_tol=1e-4)
        pad_x = 2.0 + (116.0 - 60.0 * scale) / 2.0
        assert pad_x > 2.0  # Centered horizontally

        # 120x90 in (116 x 76)
        scale_large = min(116.0 / 120.0, 76.0 / 90.0)
        assert math.isclose(scale_large, 76.0 / 90.0, rel_tol=1e-4)
        corner_x = 2.0 + 119.0 * scale_large
        corner_y = 2.0 + 89.0 * scale_large
        assert 0 <= corner_x < 120 and 0 <= corner_y < 80


class TestMinimapRuntimeThrottlingAndIndicators:
    @classmethod
    def setup_class(cls):
        js_code = """
        const fs = require('fs');
        let code = fs.readFileSync('client/webapp/js/ui/minimap_hud.js', 'utf8')
          .replace(/import\\s+[^;]+;\\s*/g, '').replace(/export\\s+(const|let)\\s+/g, 'var ')
          .replace(/export\\s+function\\s+/g, 'function ').replace(/export\\s+class\\s+MinimapHUD/g, 'MinimapHUD = class MinimapHUD')
          .replace(/export\\s+\\{[^}]+\\};?\\s*/g, '').replace(/export\\s+default\\s+[^;]+;?\\s*/g, '');

        global.window = { isGamePaused: false };
        global.document = { getElementById: () => null };
        var MinimapHUD;
        eval(code);

        const hud = new MinimapHUD('minimap-canvas');
        hud.init();
        hud.setMap(new Uint8Array(60 * 45).fill(1), 60, 45, 1, null);

        let throttledRenders = 0;
        hud.render = () => { throttledRenders++; };
        for (let frame = 0; frame < 120; frame++) {
          hud.update(frame * 8.3333, 10, 10, 0);
        }

        global.window.isGamePaused = true;
        let pauseRenders = 0;
        hud.render = () => { pauseRenders++; };
        for (let i = 0; i < 50; i++) {
          hud.update(1000 + i * 100, 10, 10, 0);
        }
        global.window.isGamePaused = false;

        const fog = new Uint8Array(60 * 45);
        fog[10 * 60 + 10] = 1;
        global.fogGrid = fog;
        global.window.fogGrid = fog;
        const metadata = {
          pois: [{ x: 5, y: 5 }, { x: 10, y: 10 }],
          bossGate: { x: 20, y: 20 },
          spawn: { x: 0, y: 0 }
        };
        hud.setMap(new Uint8Array(60 * 45).fill(1), 60, 45, 1, metadata);

        let drawnArcs = [];
        const mockCtx = {
          fillStyle: '', strokeStyle: '', lineWidth: 1,
          beginPath: () => {},
          arc: (x, y, r) => { drawnArcs.push({ x, y, r, fill: mockCtx.fillStyle }); },
          fill: () => {}, stroke: () => {}, moveTo: () => {}, lineTo: () => {}
        };

        global.window.bossGateBreached = false;
        hud.renderIndicators(mockCtx, 0, 0, 0, 1000);
        const lockedGateRed = drawnArcs.some(a => a.fill === '#ef4444');
        const poiArcs = drawnArcs.filter(a => a.fill === '#fbbf24');

        drawnArcs = [];
        global.window.bossGateBreached = true;
        hud.renderIndicators(mockCtx, 0, 0, 0, 1000);
        const unlockedGateGreen = drawnArcs.some(a => a.fill === '#10b981' && a.r === 2.5);

        console.log(JSON.stringify({
          throttledRenders, pauseRenders, lockedGateRed, unlockedGateGreen,
          exploredPoiCount: poiArcs.length
        }));
        """
        proc = subprocess.run(["node", "-e", js_code], capture_output=True, text=True, cwd=str(ROOT), check=True)
        cls.res = json.loads(proc.stdout)

    def test_battery_throttling_and_pause(self):
        """Verifies 30Hz throttling and pause freeze via Node.js runtime."""
        assert 29 <= self.res["throttledRenders"] <= 31
        assert self.res["pauseRenders"] == 0

    def test_indicator_state_logic(self):
        """Verifies Boss Gate colors and POI fog filtering via Node.js runtime."""
        assert self.res["lockedGateRed"] is True
        assert self.res["unlockedGateGreen"] is True
        assert self.res["exploredPoiCount"] == 1


class TestEntitySuppressionWiring:
    def test_world_renderer_suppression_wired(self):
        """Verifies world_renderer.js has drop, waypoint, boss gate and fog overlay hooks."""
        content = WORLD_RENDERER_JS.read_text(encoding="utf-8")
        assert "drop.wx" in content and "WarFog.getFogState" in content
        assert "wpFog" in content
        assert "WarFogRenderer" in content

    def test_entity_renderer_suppression_wired(self):
        """Verifies entity_renderer.js suppresses non-visible monsters, NPCs, and props."""
        content = ENTITY_RENDERER_JS.read_text(encoding="utf-8")
        assert "propFog" in content
        assert "mob.wx" in content and "WarFog.getFogState" in content
        assert "npc.wx" in content and "WarFog.getFogState" in content


class TestCombatTargetAntiMaphackRuntime:
    @classmethod
    def setup_class(cls):
        js_code = """
        const fs = require('fs');
        let code = fs.readFileSync('client/webapp/js/engine/monster_system.js', 'utf8');
        const match = code.match(/function getBestCombatTarget[\\s\\S]*?\\n}/);
        if (!match) throw new Error('getBestCombatTarget not found');
        eval(match[0]);

        const results = {};
        global.window = { WarFog: { getFogState: () => 0 } };
        global.activeMonsters = [{ id: 'm_unexp', wx: 3, wy: 3, hp: 100 }];
        global.window.monster = global.activeMonsters[0];
        results.unexploredTarget = getBestCombatTarget(0, 0, 7.5);
        global.window.WarFog.getFogState = () => 1;
        results.foggedTarget = getBestCombatTarget(0, 0, 7.5);
        global.activeMonsters = [];
        global.window.monster = { id: 'boss_fogged', wx: 3, wy: 3, hp: 100 };
        results.fallbackFoggedTarget = getBestCombatTarget(0, 0, 7.5);
        global.window.WarFog.getFogState = () => 2;
        global.activeMonsters = [{ id: 'm_visible', wx: 3, wy: 3, hp: 100 }];
        global.window.monster = global.activeMonsters[0];
        const resVis = getBestCombatTarget(0, 0, 7.5);
        results.visibleTargetId = resVis ? resVis.id : null;
        global.activeMonsters = [];
        global.window.monster = { id: 'boss_visible', wx: 4, wy: 4, hp: 500 };
        const resFallback = getBestCombatTarget(0, 0, 7.5);
        results.fallbackVisibleId = resFallback ? resFallback.id : null;
        global.window.monster = { id: 'boss_far', wx: 10, wy: 10, hp: 500 };
        results.beyondRangeTarget = getBestCombatTarget(0, 0, 7.5);
        global.activeMonsters = [{ id: 'm_dead', wx: 3, wy: 3, hp: 0 }];
        global.window.monster = { id: 'boss_dead', wx: 3, wy: 3, hp: 0 };
        results.deadTarget = getBestCombatTarget(0, 0, 7.5);
        console.log(JSON.stringify(results));
        """
        proc = subprocess.run(["node", "-e", js_code], capture_output=True, text=True, cwd=str(ROOT), check=True)
        cls.res = json.loads(proc.stdout)

    def test_unexplored_and_fogged_target_returns_null(self):
        """Monsters in unexplored (0) or fogged (1) tiles cannot be targeted."""
        assert self.res["unexploredTarget"] is None
        assert self.res["foggedTarget"] is None
        assert self.res["fallbackFoggedTarget"] is None

    def test_visible_target_within_range_returns_monster(self):
        """Visible monsters within maxRange are acquired."""
        assert self.res["visibleTargetId"] == "m_visible"
        assert self.res["fallbackVisibleId"] == "boss_visible"

    def test_visible_target_beyond_range_returns_null(self):
        """Visible monsters beyond maxRange return null."""
        assert self.res["beyondRangeTarget"] is None

    def test_dead_target_ignored(self):
        """Dead monsters (hp <= 0) cannot be targeted."""
        assert self.res["deadTarget"] is None
