"""
Empirical Adversarial Challenge Suite for Milestone 2: Character Animation & Visceral Combat Feel Engine.
Author: challenger_m2_1_gen3
Roles: critic, specialist

This suite stress-tests assumptions and empirically reproduces bugs in:
1. Kinetic state machine phase synchronization across frame rates (15-240 FPS) and attack speeds (0.5-2.0x).
2. Action cancellation and clip reset in AnimationEngine.cancelAction.
3. Hit-stop timer decay in CombatFeelEngine.update.
4. Directional screen shake offset retrieval via CombatFeelEngine.getShakeOffset.
5. Hit-stop frame micro-freeze duration and DamageNumberPool zero-allocation ring buffer.
"""

from __future__ import annotations
import json
import subprocess
import pytest
from pathlib import Path

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


def run_node_snippet(code: str) -> dict:
    """Executes a JS snippet in Node.js and parses JSON stdout."""
    runner = f"""
    globalThis.window = globalThis;
    (async () => {{
        try {{
            {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
    )
    if res.returncode != 0:
        raise RuntimeError(f"Node execution failed (code {res.returncode}):\nSTDOUT: {res.stdout}\nSTDERR: {res.stderr}")
    
    lines = [line.strip() for line in res.stdout.strip().splitlines() if line.strip()]
    json_lines = [l for l in lines if l.startswith("{") and l.endswith("}")]
    if not json_lines:
        raise ValueError(f"No JSON returned by script:\n{res.stdout}")
    return json.loads(json_lines[-1])


class TestAdversarialKineticStateMachine:
    """Stress-tests 5-phase kinetic machine transitions across FPS and attack speeds."""

    @pytest.mark.parametrize("fps,dt", [
        (15, 1.0 / 15.0),
        (30, 1.0 / 30.0),
        (60, 1.0 / 60.0),
        (120, 1.0 / 120.0),
        (240, 1.0 / 240.0),
    ])
    @pytest.mark.parametrize("action_speed", [0.5, 1.0, 1.5, 2.0])
    @pytest.mark.parametrize("archetype_clip", ["attack_slash", "attack_thrust", "attack_slam", "attack_bolt"])
    def test_kinetic_transition_phases_progression(self, fps: int, dt: float, action_speed: float, archetype_clip: str) -> None:
        """Verifies full lifecycle transitions: idle -> run -> wind_up -> (impact) -> recovery -> idle."""
        js_code = f"""
        await import('./client/webapp/js/engine/animation_engine.js');
        const {{ createAnimationState, updateAnimation, playAction, KINETIC_PHASES }} = window.AnimationEngine;
        
        const state = createAnimationState('hero');
        const recordedPhases = [];
        
        // 1. Initial state
        recordedPhases.push(state.kineticPhase);
        
        // 2. Transition to Run
        updateAnimation(state, 3.2, {dt});
        recordedPhases.push(state.kineticPhase);
        
        // 3. Stop moving -> Idle
        updateAnimation(state, 0.0, {dt});
        recordedPhases.push(state.kineticPhase);
        
        // 4. Trigger attack clip
        let hitTriggered = false;
        playAction(state, '{archetype_clip}', {{
            actionSpeed: {action_speed},
            onHit: () => {{ hitTriggered = true; }}
        }});
        recordedPhases.push(state.kineticPhase);
        
        // 5. Advance frames through the full action
        let iterations = 0;
        let visitedImpact = false;
        let visitedRecovery = false;
        
        while (state.currentClip === '{archetype_clip}' && iterations < 300) {{
            updateAnimation(state, 0.0, {dt});
            if (state.kineticPhase === 'impact') visitedImpact = true;
            if (state.kineticPhase === 'recovery') visitedRecovery = true;
            recordedPhases.push(state.kineticPhase);
            iterations++;
        }}
        
        // Final state should auto-recover to idle
        recordedPhases.push(state.kineticPhase);
        
        console.log(JSON.stringify({{
            finalClip: state.currentClip,
            finalPhase: state.kineticPhase,
            hitTriggered: hitTriggered,
            visitedImpact: visitedImpact,
            visitedRecovery: visitedRecovery,
            totalIterations: iterations
        }}));
        """
        res = run_node_snippet(js_code)
        assert res["finalClip"] == "idle", f"Expected auto-recovery to idle, got {res['finalClip']}"
        assert res["finalPhase"] == "idle", f"Expected final phase idle, got {res['finalPhase']}"
        assert res["hitTriggered"] is True, f"Attack hit callback failed to trigger for {archetype_clip} at {fps} FPS, speed {action_speed}"
        assert res["visitedRecovery"] is True, f"Recovery phase never reached for {archetype_clip} at {fps} FPS, speed {action_speed}"

    def test_kinetic_phase_frame_index_synchronization(self) -> None:
        """
        BUG REPRODUCTION:
        Verifies whether kineticPhase is synchronized with frameIndex in the SAME tick,
        rather than lagging 1 tick behind due to premature phase calculation before frame advance.
        """
        js_code = """
        await import('./client/webapp/js/engine/animation_engine.js');
        const { createAnimationState, updateAnimation, playAction } = window.AnimationEngine;
        const state = createAnimationState('hero');
        
        // attack_slash has hit_frame = 2
        playAction(state, 'attack_slash', { actionSpeed: 1.0 });
        
        let desyncCount = 0;
        // Advance 20 ticks at 60 FPS
        for (let i = 0; i < 20; i++) {
            updateAnimation(state, 0.0, 0.01667);
            if (state.currentClip === 'attack_slash') {
                if (state.frameIndex < 2 && state.kineticPhase !== 'wind_up') desyncCount++;
                if (state.frameIndex === 2 && state.kineticPhase !== 'impact') desyncCount++;
                if (state.frameIndex > 2 && state.kineticPhase !== 'recovery') desyncCount++;
            }
        }
        
        console.log(JSON.stringify({ desyncCount: desyncCount }));
        """
        res = run_node_snippet(js_code)
        assert res["desyncCount"] == 0, f"Detected {res['desyncCount']} de-synchronized ticks between frameIndex and kineticPhase!"


class TestAdversarialCombatFeelEngine:
    """Stress-tests hit-stop, camera shake, and damage number ring buffer."""

    def test_missing_get_shake_offset_defect(self) -> None:
        """
        BUG REPRODUCTION:
        canvas_renderer.js calls window.combatFeelEngine.getShakeOffset?.().
        CombatFeelEngine computes shakeOffsetX and shakeOffsetY but must provide getShakeOffset()
        so the renderer can actually apply the directional camera shake matrix.
        """
        js_code = """
        const { CombatFeelEngine } = await import('./client/webapp/js/engine/combat_feel_engine.js');
        const cfe = new CombatFeelEngine();
        
        cfe.triggerScreenShake(5.0, 0.25, 0.785);
        cfe.update(0.016);
        
        const hasMethod = typeof cfe.getShakeOffset === 'function';
        const offset = hasMethod ? cfe.getShakeOffset() : null;
        
        console.log(JSON.stringify({
            hasMethod: hasMethod,
            offset: offset,
            hasOffsetX: typeof cfe.shakeOffsetX === 'number',
            hasOffsetY: typeof cfe.shakeOffsetY === 'number'
        }));
        """
        res = run_node_snippet(js_code)
        assert res["hasMethod"] is True, "CombatFeelEngine.prototype.getShakeOffset is missing! Directional screen shake is dead code in canvas_renderer.js"
        assert res["offset"] is not None and "x" in res["offset"] and "y" in res["offset"], "getShakeOffset did not return {x, y} coordinate object"

    def test_cancel_action_resets_current_clip_defect(self) -> None:
        """
        BUG REPRODUCTION:
        AnimationEngine.cancelAction must reset currentClip to 'idle', otherwise
        the subsequent updateAnimation call re-computes kineticPhase back to attack.
        """
        js_code = """
        await import('./client/webapp/js/engine/animation_engine.js');
        const { createAnimationState, updateAnimation, playAction, cancelAction } = window.AnimationEngine;
        const state = createAnimationState('hero');
        
        playAction(state, 'attack_slash');
        cancelAction(state);
        
        const clipAfterCancel = state.currentClip;
        
        // Tick updateAnimation after cancel
        updateAnimation(state, 0.0, 0.016);
        const clipAfterTick = state.currentClip;
        const phaseAfterTick = state.kineticPhase;
        
        console.log(JSON.stringify({
            clipAfterCancel: clipAfterCancel,
            clipAfterTick: clipAfterTick,
            phaseAfterTick: phaseAfterTick
        }));
        """
        res = run_node_snippet(js_code)
        assert res["clipAfterCancel"] == "idle", f"cancelAction left currentClip as '{res['clipAfterCancel']}', expected 'idle'"
        assert res["clipAfterTick"] == "idle", f"updateAnimation after cancel reverted clip to '{res['clipAfterTick']}'"
        assert res["phaseAfterTick"] == "idle", f"updateAnimation after cancel reverted phase to '{res['phaseAfterTick']}', expected 'idle'"

    def test_hit_stop_timer_decays_in_update_loop_defect(self) -> None:
        """
        BUG REPRODUCTION:
        CombatFeelEngine.update(dt) must decrement hitStopTimer so that the hit-stop pause
        resolves in the simulation loop without requiring an external manual isHitStopped caller.
        """
        js_code = """
        const { CombatFeelEngine } = await import('./client/webapp/js/engine/combat_feel_engine.js');
        const cfe = new CombatFeelEngine();
        
        cfe.triggerHitStop(3, 0.05);
        const initialTimer = cfe.hitStopTimer;
        
        // Run update loop for 0.1s (longer than 0.05s hit-stop)
        for (let i = 0; i < 6; i++) {
            cfe.update(0.016);
        }
        const timerAfterUpdates = cfe.hitStopTimer;
        
        console.log(JSON.stringify({
            initialTimer: initialTimer,
            timerAfterUpdates: timerAfterUpdates
        }));
        """
        res = run_node_snippet(js_code)
        assert res["initialTimer"] > 0, "hitStopTimer was not initialized"
        assert res["timerAfterUpdates"] == 0, f"hitStopTimer failed to decay in update(dt)! Still {res['timerAfterUpdates']} after 0.1s"

    @pytest.mark.parametrize("archetype_name,expected_frames,expected_duration", [
        ("SLASH", 3, 0.05),
        ("THRUST", 2, 0.035),
        ("SLAM", 4, 0.066),
        ("BOLT", 2, 0.035),
    ])
    def test_attacker_and_target_frame_freeze_duration(self, archetype_name: str, expected_frames: int, expected_duration: float) -> None:
        """Verifies attacker and target both halt frame advancement for 33ms-66ms."""
        js_code = f"""
        await import('./client/webapp/js/engine/animation_engine.js');
        const {{ createAnimationState, updateAnimation, playAction }} = window.AnimationEngine;
        const {{ CombatFeelEngine }} = await import('./client/webapp/js/engine/combat_feel_engine.js');
        const {{ getWeaponSwingArchetype }} = await import('./client/webapp/js/data/weapon_swing_catalog.js');
        
        const cfe = new CombatFeelEngine();
        const arch = getWeaponSwingArchetype('{archetype_name}');
        
        const attackerAnim = createAnimationState('hero');
        playAction(attackerAnim, 'attack_slash');
        attackerAnim.frameIndex = 2;
        attackerAnim.frameTime = 2.0;
        
        const targetAnim = createAnimationState('mob_skeleton_warrior');
        playAction(targetAnim, 'run');
        targetAnim.frameIndex = 1;
        targetAnim.frameTime = 1.0;
        
        const attacker = {{ anim: attackerAnim }};
        const target = {{ anim: targetAnim, hitStopTimer: 0 }};
        
        cfe.triggerHitStop(arch.hitStopFrames, arch.hitStopDuration, attacker, target);
        
        const dt = 0.016;
        let attackerFrozenTicks = 0;
        let targetFrozenTicks = 0;
        
        for (let tick = 0; tick < 10; tick++) {{
            const prevAttackerFrame = attackerAnim.frameTime;
            const prevTargetFrame = targetAnim.frameTime;
            
            updateAnimation(attackerAnim, 0, dt);
            updateAnimation(targetAnim, 1.8, dt);
            
            if (attackerAnim.frameTime === prevAttackerFrame) attackerFrozenTicks++;
            if (targetAnim.frameTime === prevTargetFrame) targetFrozenTicks++;
        }}
        
        console.log(JSON.stringify({{
            expectedFrames: arch.hitStopFrames,
            attackerFrozenTicks: attackerFrozenTicks,
            targetFrozenTicks: targetFrozenTicks,
            duration: arch.hitStopDuration
        }}));
        """
        res = run_node_snippet(js_code)
        assert res["attackerFrozenTicks"] >= res["expectedFrames"]
        assert res["targetFrozenTicks"] >= res["expectedFrames"]
        assert 0.033 <= res["duration"] <= 0.066

    def test_damage_number_pool_wraparound_and_zero_allocation(self) -> None:
        """Spawns 500 damage numbers; verifies strict 64-slot capacity, zero allocations, no leaks."""
        js_code = """
        const { CombatFeelEngine } = await import('./client/webapp/js/engine/combat_feel_engine.js');
        const cfe = new CombatFeelEngine();
        
        const initialCapacity = cfe.damagePool.length;
        
        for (let i = 0; i < 256; i++) {
            cfe.spawnDamageNumber(`-${i * 10}`, 100 + i, 200, i % 5 === 0 ? '#ef4444' : '#ffffff', i % 5 === 0);
        }
        
        const postSpawnCapacity = cfe.damagePool.length;
        const poolHead = cfe.poolHead;
        
        const allActive = cfe.damagePool.every(slot => slot.active === true);
        const allValidScales = cfe.damagePool.every(slot => typeof slot.scale === 'number' && !isNaN(slot.scale));
        
        cfe.update(0.1);
        const updatedValidScales = cfe.damagePool.every(slot => slot.scale >= 1.0);
        
        console.log(JSON.stringify({
            initialCapacity: initialCapacity,
            postSpawnCapacity: postSpawnCapacity,
            poolHead: poolHead,
            allActive: allActive,
            allValidScales: allValidScales,
            updatedValidScales: updatedValidScales
        }));
        """
        res = run_node_snippet(js_code)
        assert res["initialCapacity"] == 64
        assert res["postSpawnCapacity"] == 64
        assert res["poolHead"] == 0
        assert res["allActive"] is True
        assert res["allValidScales"] is True
        assert res["updatedValidScales"] is True
