"""Unit Test Suite for FreeExile Animation Atlas Frame Mapping, Pivot Bounding Box & Turn Inertia Snap Freeze."""
from __future__ import annotations
import json
import math
from pathlib import Path
import subprocess
import pytest

PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent
WEBAPP_DIR = PROJECT_ROOT / "client" / "webapp"
ANIM_DIR = WEBAPP_DIR / "assets" / "animations"
ENGINE_DIR = WEBAPP_DIR / "js" / "engine"
CLASSES = ["char_sword_master", "char_sword_maiden", "char_feral_berserker", "char_wild_archer", "char_glacial_lancer", "char_shadow_assassin"]


def run_node_js(js_code: str) -> dict:
    runner = f"""
    globalThis.window = globalThis;
    globalThis.document = {{ readyState: 'complete', addEventListener: () => {{}}, getElementById: () => null }};
    const fs = await import('node:fs');
    new Function(fs.readFileSync('./client/webapp/js/engine/animation_engine.js', 'utf-8'))();
    const ae = window.AnimationEngine;
    globalThis.ASSETS = globalThis.ASSETS || {{}};
    globalThis.ASSETS['hero_anim_atlas'] = {{ complete: true, naturalWidth: 1280, naturalHeight: 960 }};
    const makeCtx = () => {{
        const captured = [];
        return {{
            captured, save() {{}}, restore() {{}}, translate() {{}}, scale() {{}}, rotate() {{}},
            fillRect() {{}}, strokeRect() {{}}, fillText() {{}},
            drawImage(img, sx, sy, sw, sh, dx, dy, dw, dh) {{ captured.push({{ sx, sy, sw, sh, dx, dy, dw, dh }}); }}
        }};
    }};
    (async () => {{
        try {{
            {js_code}
        }} catch (err) {{
            console.error(err);
            process.exit(1);
        }}
    }})();
    """
    res = subprocess.run(["node", "--input-type=module", "-e", runner], cwd=str(PROJECT_ROOT), capture_output=True, text=True)
    assert res.returncode == 0, f"Node script execution failed:\n{res.stderr}\nCode:\n{js_code}"
    return json.loads(res.stdout.strip())


class TestAtlasFrameMapping:
    """Verifies R1: Exact atlas grid frame mapping and pivot reading."""

    @pytest.mark.parametrize("class_id", CLASSES)
    @pytest.mark.parametrize("clip_name", ["idle", "run", "attack_slash", "dodge"])
    def test_all_classes_match_manifest_coordinates(self, class_id: str, clip_name: str) -> None:
        manifest_data = json.loads((ANIM_DIR / f"{class_id}_anim_manifest.json").read_text(encoding="utf-8"))
        js_code = f"""
        globalThis.ASSETS['{class_id}_anim_atlas'] = {{ complete: true, naturalWidth: 1280, naturalHeight: 960 }};
        const ctx = makeCtx(), state = ae.createAnimationState('{class_id}');
        state.currentClip = '{clip_name}';
        const clip = ae.MANIFESTS['{class_id}']?.animations['{clip_name}'] || ae.MANIFESTS['hero']?.animations['{clip_name}'];
        const results = [];
        for (let f = 0; f < clip.frames.length; f++) {{
            state.frameIndex = f; ctx.captured.length = 0;
            ae.drawEntityFrame(ctx, state, 100, 100, 1, 100, 125);
            results.push(ctx.captured[0] || null);
        }}
        console.log(JSON.stringify({{ results }}));
        """
        results = run_node_js(js_code)["results"]
        clip_frames = manifest_data["animations"][clip_name]["frames"]
        assert len(results) == len(clip_frames)
        for idx, fkey in enumerate(clip_frames):
            exp = manifest_data["frames"][fkey]
            assert results[idx]["sx"] == exp["x"] and results[idx]["sy"] == exp["y"]

    def test_aliased_attack_clips_do_not_skew_subsequent_clips(self) -> None:
        js_code = """
        const ctx = makeCtx(), state = ae.createAnimationState('hero');
        const checkClip = (clip) => {
            state.currentClip = clip; state.frameIndex = 0; ctx.captured.length = 0;
            ae.drawEntityFrame(ctx, state, 0, 0, 1, 100, 125);
            return ctx.captured[0];
        };
        console.log(JSON.stringify({
            thrust0: checkClip('attack_thrust'), slam0: checkClip('attack_slam'), bolt0: checkClip('attack_bolt'),
            whirlwind0: checkClip('skill_whirlwind'), dodge0: checkClip('dodge'), hurt0: checkClip('hurt')
        }));
        """
        out = run_node_js(js_code)
        assert out["thrust0"]["sx"] == 640 and out["thrust0"]["sy"] == 192
        assert out["slam0"]["sx"] == 640 and out["slam0"]["sy"] == 192
        assert out["bolt0"]["sx"] == 640 and out["bolt0"]["sy"] == 192
        assert out["whirlwind0"]["sx"] == 320 and out["whirlwind0"]["sy"] == 384
        assert out["dodge0"]["sx"] == 0 and out["dodge0"]["sy"] == 576
        assert out["hurt0"]["sx"] == 960 and out["hurt0"]["sy"] == 576

    def test_pivot_bounding_box_reads_manifest_frame_pivot(self) -> None:
        js_code = """
        ae.MANIFESTS['custom_test'] = {
            texture_width: 1280, texture_height: 960, frame_width: 160, frame_height: 192,
            frames: { 'idle_0': { x: 0, y: 0, w: 160, h: 192, pivot: [0.4, 0.75] } },
            animations: { idle: { frames: ['idle_0'], fps: 6, loop: true } }
        };
        globalThis.ASSETS['custom_test_anim_atlas'] = { complete: true, naturalWidth: 1280, naturalHeight: 960 };
        const ctx = makeCtx(), state = ae.createAnimationState('custom_test');
        ae.drawEntityFrame(ctx, state, 100, 100, 1, 100, 200);
        console.log(JSON.stringify({ cap: ctx.captured[0] }));
        """
        cap = run_node_js(js_code)["cap"]
        assert math.isclose(cap["dx"], -40.0, abs_tol=0.1) and math.isclose(cap["dy"], -150.0, abs_tol=0.1)

    def test_fallback_clip_on_unknown_animation_draws_idle_frame_zero(self) -> None:
        js_code = """
        const ctx = makeCtx(), state = ae.createAnimationState('hero');
        state.currentClip = 'nonexistent_test_action';
        ae.drawEntityFrame(ctx, state, 0, 0, 1, 100, 125);
        console.log(JSON.stringify({ cap: ctx.captured[0] }));
        """
        cap = run_node_js(js_code)["cap"]
        assert cap is not None and cap["sx"] == 0 and cap["sy"] == 0

    def test_negative_frame_index_wraps_modulo_cleanly(self) -> None:
        js_code = """
        const ctx = makeCtx(), state = ae.createAnimationState('hero');
        state.currentClip = 'idle'; state.frameIndex = -1;
        ae.drawEntityFrame(ctx, state, 0, 0, 1, 100, 125);
        console.log(JSON.stringify({ cap: ctx.captured[0] }));
        """
        cap = run_node_js(js_code)["cap"]
        assert cap["sx"] == 480 and cap["sy"] == 0

    def test_mob_atlas_does_not_draw_hero_sprite(self) -> None:
        js_code = """
        const ctx = makeCtx(), state = ae.createAnimationState('mob_feral_hellhound');
        console.log(JSON.stringify({ drawn: ae.drawEntityFrame(ctx, state, 0, 0, 1, 80, 80) }));
        """
        assert run_node_js(js_code)["drawn"] is False

    def test_empty_frames_clip_returns_false(self) -> None:
        js_code = """
        ae.MANIFESTS['empty_test'] = {
            texture_width: 1280, texture_height: 960, frame_width: 160, frame_height: 192,
            animations: { empty_clip: { frames: [] } }
        };
        globalThis.ASSETS['empty_test_anim_atlas'] = { complete: true, naturalWidth: 1280, naturalHeight: 960 };
        const ctx = makeCtx(), state = ae.createAnimationState('empty_test');
        state.currentClip = 'empty_clip';
        console.log(JSON.stringify({ drawn: ae.drawEntityFrame(ctx, state, 0, 0, 1, 100, 100) }));
        """
        assert run_node_js(js_code)["drawn"] is False

    def test_circular_alias_cycle_safety(self) -> None:
        js_code = """
        ae.MANIFESTS['circular_cycle'] = {
            texture_width: 1280, texture_height: 960, frame_width: 160, frame_height: 192,
            animations: { loop_a: { atlas_clip: 'loop_b' }, loop_b: { atlas_clip: 'loop_a' } }
        };
        const ctx = makeCtx(), state = ae.createAnimationState('circular_cycle');
        state.currentClip = 'loop_a';
        console.log(JSON.stringify({ drawn: ae.drawEntityFrame(ctx, state, 0, 0, 1, 100, 100) }));
        """
        assert run_node_js(js_code)["drawn"] is False

    def test_nan_and_infinite_pivot_fallback(self) -> None:
        js_code = """
        ae.MANIFESTS['nan_pivot'] = {
            texture_width: 1280, texture_height: 960, frame_width: 160, frame_height: 192,
            frames: { 'idle_0': { x: 0, y: 0, w: 160, h: 192, pivot: [NaN, Infinity] } },
            animations: { idle: { frames: ['idle_0'], fps: 6, loop: true } }
        };
        globalThis.ASSETS['nan_pivot_anim_atlas'] = { complete: true, naturalWidth: 1280, naturalHeight: 960 };
        const ctx = makeCtx(), state = ae.createAnimationState('nan_pivot');
        ae.drawEntityFrame(ctx, state, 100, 100, 1, 100, 200);
        console.log(JSON.stringify({ cap: ctx.captured[0] }));
        """
        cap = run_node_js(js_code)["cap"]
        assert cap is not None and math.isclose(cap["dx"], -50.0, abs_tol=0.1) and math.isclose(cap["dy"], -180.0, abs_tol=0.1)

    def test_negative_frame_pivot_handling(self) -> None:
        """Negative frame pivots calculate proper offset coordinates without NaN."""
        js_code = """
        ae.MANIFESTS['neg_pivot'] = {
            texture_width: 1280, texture_height: 960, frame_width: 160, frame_height: 192,
            frames: { 'i0': { x: 0, y: 0, w: 160, h: 192, pivot: [-0.25, -0.5] } },
            animations: { idle: { frames: ['i0'], fps: 6 } }
        };
        globalThis.ASSETS['neg_pivot_anim_atlas'] = { complete: true, naturalWidth: 1280, naturalHeight: 960 };
        const ctx = makeCtx(), state = ae.createAnimationState('neg_pivot');
        ae.drawEntityFrame(ctx, state, 100, 100, 1, 100, 200);
        console.log(JSON.stringify({ cap: ctx.captured[0] }));
        """
        cap = run_node_js(js_code)["cap"]
        assert math.isclose(cap["dx"], 25.0, abs_tol=0.1) and math.isclose(cap["dy"], 100.0, abs_tol=0.1)

    def test_malformed_frame_dimensions_fallback(self) -> None:
        """Frames with non-finite or zero dimensions safely fallback to manifest defaults."""
        js_code = """
        ae.MANIFESTS['malformed_frame'] = {
            texture_width: 1280, texture_height: 960, frame_width: 160, frame_height: 192,
            frames: { 'bad0': { x: NaN, y: Infinity, w: 0, h: -10 } },
            animations: { idle: { frames: ['bad0'], fps: 6 } }
        };
        globalThis.ASSETS['malformed_frame_anim_atlas'] = { complete: true, naturalWidth: 1280, naturalHeight: 960 };
        const ctx = makeCtx(), state = ae.createAnimationState('malformed_frame');
        const drawn = ae.drawEntityFrame(ctx, state, NaN, 100, 1, 100, 200);
        console.log(JSON.stringify({ drawn, cap: ctx.captured[0] }));
        """
        res = run_node_js(js_code)
        assert res["drawn"] is True and res["cap"]["sx"] == 0 and res["cap"]["sy"] == 0

    def test_null_and_undefined_anim_state_safety(self) -> None:
        js_code = """
        const ctx = makeCtx();
        let errUpdate = null, errDraw = null;
        try { ae.updateAnimation(null, 0.0, 0.016); } catch (e) { errUpdate = e.message; }
        try { ae.drawEntityFrame(ctx, undefined, 0, 0, 1, 100, 100); } catch (e) { errDraw = e.message; }
        console.log(JSON.stringify({ errUpdate, errDraw }));
        """
        out = run_node_js(js_code)
        assert out["errUpdate"] is None and out["errDraw"] is None


class TestTurnInertiaSnapFreeze:
    """Verifies R2: Smooth turn inertia freezes immediately on release without residual rotation."""

    def test_smooth_angle_stops_lerp_immediately_when_velocity_zero(self) -> None:
        js_code = """
        const state = ae.createAnimationState('hero'), moveDir = { x: 0.707, y: -0.707 };
        for (let i = 0; i < 10; i++) ae.updateAnimation(state, 4.2, 0.016, moveDir);
        const movingAngle = state.smoothAngle, stoppedFacing = { x: 1, y: 0 };
        ae.updateAnimation(state, 0.0, 0.016, stoppedFacing);
        const stoppedAngle1 = state.smoothAngle;
        for (let i = 0; i < 20; i++) ae.updateAnimation(state, 0.0, 0.016, stoppedFacing);
        console.log(JSON.stringify({ movingAngle, stoppedAngle1, stoppedAngle20: state.smoothAngle }));
        """
        out = run_node_js(js_code)
        assert out["stoppedAngle1"] == out["movingAngle"] and out["stoppedAngle20"] == out["movingAngle"]

    def test_transition_run_to_idle_in_single_frame(self) -> None:
        js_code = """
        const state = ae.createAnimationState('hero');
        state.currentClip = 'run';
        ae.updateAnimation(state, 4.2, 0.016, { x: 1, y: 0 });
        const movingClip = state.currentClip, movingPhase = state.kineticPhase;
        ae.updateAnimation(state, 0.0, 0.016, { x: 1, y: 0 });
        const stoppedClip = state.currentClip, stoppedPhase = state.kineticPhase;
        ae.updateAnimation(state, 4.2, 0.016, { x: 1, y: 0 });
        console.log(JSON.stringify({ movingClip, movingPhase, stoppedClip, stoppedPhase, resClip: state.currentClip, resPhase: state.kineticPhase }));
        """
        out = run_node_js(js_code)
        assert out["movingClip"] == "run" and out["movingPhase"] == "run"
        assert out["stoppedClip"] == "idle" and out["stoppedPhase"] == "idle"
        assert out["resClip"] == "run" and out["resPhase"] == "run"

    def test_negative_velocity_magnitude_handling(self) -> None:
        js_code = """
        const state = ae.createAnimationState('hero');
        ae.updateAnimation(state, -4.2, 0.016, { x: 1, y: 0 });
        console.log(JSON.stringify({ clip: state.currentClip, phase: state.kineticPhase }));
        """
        out = run_node_js(js_code)
        assert out["clip"] == "run" and out["phase"] == "run"

    def test_action_locked_animation_not_overwritten_by_velocity(self) -> None:
        js_code = """
        const state = ae.createAnimationState('hero');
        ae.playAction(state, 'attack_slash');
        ae.updateAnimation(state, 0.0, 0.016, { x: 1, y: 0 });
        console.log(JSON.stringify({ clip: state.currentClip, locked: state.isActionLocked }));
        """
        out = run_node_js(js_code)
        assert out["clip"] == "attack_slash" and out["locked"] is True

    def test_click_to_move_heading_and_snap_freeze(self) -> None:
        js_code = """
        const state = ae.createAnimationState('hero');
        const p = { wx: 0, wy: 0, speed: 4.2, anim: state, destination: { wx: 10, wy: 5 } };
        const dx = p.destination.wx - p.wx, dy = p.destination.wy - p.wy, dist = Math.hypot(dx, dy);
        ae.updateAnimation(state, p.speed, 0.016, { x: (dx/dist - dy/dist)*0.5, y: (dx/dist + dy/dist)*0.25 });
        const movingAngle = state.smoothAngle;
        ae.updateAnimation(state, 0.0, 0.016, null);
        const arrivalAngle = state.smoothAngle;
        for (let i = 0; i < 10; i++) ae.updateAnimation(state, 0.0, 0.016, null);
        console.log(JSON.stringify({ movingAngle, arrivalAngle, postAngle: state.smoothAngle }));
        """
        out = run_node_js(js_code)
        assert out["arrivalAngle"] == out["movingAngle"] and out["postAngle"] == out["movingAngle"]

    def test_rapid_120hz_analog_boundary_no_flutter(self) -> None:
        js_code = """
        const state = ae.createAnimationState('hero'), dt = 1.0 / 120.0, dir = { x: 0.8, y: 0.6 };
        ae.updateAnimation(state, 4.2, dt, dir);
        const angles = [];
        for (let f = 0; f < 120; f++) {
            const mag = (f % 2 === 0) ? 0.04 : 0.06;
            ae.updateAnimation(state, (mag >= 0.05) ? 4.2 : 0, dt, (mag >= 0.05) ? dir : null);
            angles.push(state.smoothAngle);
        }
        let flutterCount = 0;
        for (let f = 1; f < angles.length; f += 2) {
            if (f + 1 < angles.length && angles[f + 1] !== angles[f]) flutterCount++;
        }
        console.log(JSON.stringify({ flutterCount }));
        """
        assert run_node_js(js_code)["flutterCount"] == 0

    def test_manifest_without_kinematics_safe(self) -> None:
        js_code = """
        ae.MANIFESTS['no_kin'] = { texture_width: 1280, texture_height: 960, frame_width: 160, frame_height: 192, animations: { idle: { frames: ['i0'], fps: 6 } } };
        const state = ae.createAnimationState('no_kin');
        let error = null;
        try { ae.updateAnimation(state, 2.0, 0.016, { x: 1, y: 0 }); } catch (e) { error = e.message; }
        console.log(JSON.stringify({ error }));
        """
        assert run_node_js(js_code)["error"] is None

    def test_infinite_velocity_and_dt_finite_safety(self) -> None:
        """Infinity velocity or dt safely falls back without creating NaN frameIndex."""
        js_code = """
        const state1 = ae.createAnimationState('hero'), state2 = ae.createAnimationState('hero');
        ae.updateAnimation(state1, Infinity, 0.016);
        ae.updateAnimation(state2, 4.2, Infinity);
        console.log(JSON.stringify({
            idx1: state1.frameIndex, finite1: Number.isFinite(state1.frameTime),
            idx2: state2.frameIndex, finite2: Number.isFinite(state2.frameTime)
        }));
        """
        out = run_node_js(js_code)
        assert out["finite1"] is True and out["idx1"] == 0
        assert out["finite2"] is True and out["idx2"] == 0

    def test_non_finite_heading_degree_fallback(self) -> None:
        """Non-finite degrees safely default to S heading."""
        js_code = "console.log(JSON.stringify({ h1: ae.get8DirectionHeading(NaN), h2: ae.get8DirectionHeading(Infinity) }));"
        out = run_node_js(js_code)
        assert out["h1"] == "S" and out["h2"] == "S"


class TestCodeQualityAndDebugOverlay:
    """Verifies R3 and Code Quality constraints."""

    def test_animation_engine_line_cap_strictly_under_350(self) -> None:
        lines = len((ENGINE_DIR / "animation_engine.js").read_text(encoding="utf-8").splitlines())
        assert lines <= 350, f"animation_engine.js line count {lines} exceeds soft cap of 350"

    def test_debug_overlay_hidden_by_default(self) -> None:
        js_code = "console.log(JSON.stringify({ debugAnim: window.__DEBUG_ANIM__ }));"
        assert run_node_js(js_code)["debugAnim"] is False
