"""
Unit Test Suite for Milestone 2: Character Animation & Visceral Combat Feel Engine.
Tests:
1. 5-Phase Kinetic State Machine & Archetype Clips (animation_engine.js)
2. Micro Hit-Stop, Screen Shake Clamping, and Damage Number Ring Buffer (combat_feel_engine.js)
3. Weapon Swing Archetypes Catalog (weapon_swing_catalog.js)
4. Animation Cancelling & Weapon Swing Triggers (combat_skills.js)
5. Canvas Renderer Game Loop Wiring (canvas_renderer.js)
6. Strict Quantitative Line Length Hygiene Caps (GEMINI.md & PROJECT.md)
"""

from __future__ import annotations
import json
import math
import os
import re
import subprocess
from pathlib import Path
import pytest

PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent
WEBAPP_DIR = PROJECT_ROOT / "client" / "webapp"
ENGINE_DIR = WEBAPP_DIR / "js" / "engine"
DATA_DIR = WEBAPP_DIR / "js" / "data"


class TestAnimationEngineKineticMachine:
    """Verifies 5-phase kinetic machine and archetype animation clips in animation_engine.js."""

    @pytest.fixture(autouse=True)
    def setup_code(self) -> None:
        self.code = (ENGINE_DIR / "animation_engine.js").read_text(encoding="utf-8")

    def test_archetype_animation_clips_defined(self) -> None:
        """Verifies attack_slash, attack_thrust, attack_slam, attack_bolt clips exist."""
        assert "attack_slash:" in self.code
        assert "attack_thrust:" in self.code
        assert "attack_slam:" in self.code
        assert "attack_bolt:" in self.code

    def test_archetype_hit_frame_and_atlas_aliasing(self) -> None:
        """Verifies hit_frame defined and atlas_clip aliased to prevent sprite index overflow."""
        assert "atlas_clip: 'attack_slash'" in self.code or 'atlas_clip: "attack_slash"' in self.code
        # Check hit_frame present in archetype attacks
        assert "hit_frame: 2" in self.code
        assert "hit_frame: 3" in self.code

    def test_kinetic_phases_constant_and_values(self) -> None:
        """Verifies KINETIC_PHASES enum contains all canonical phases."""
        assert "KINETIC_PHASES" in self.code
        for phase in ["idle", "run", "wind_up", "impact", "recovery", "hit_stop", "dodge"]:
            assert phase in self.code

    def test_continuous_kinetic_phase_computation(self) -> None:
        """Verifies updateAnimation continuously computes state.kineticPhase across all 5 phases."""
        assert "animState.kineticPhase = 'hit_stop'" in self.code
        assert "animState.kineticPhase = 'wind_up'" in self.code
        assert "animState.kineticPhase = 'impact'" in self.code
        assert "animState.kineticPhase = 'recovery'" in self.code
        assert "animState.kineticPhase = 'run'" in self.code
        assert "animState.kineticPhase = 'idle'" in self.code
        assert "animState.kineticPhase = 'dodge'" in self.code

    def test_cancel_action_resets_locks_and_phase(self) -> None:
        """Verifies cancelAction function resets action lock and callbacks."""
        assert "function cancelAction" in self.code
        assert "cancelAction" in self.code
        assert "isActionLocked = false" in self.code

    def test_animation_engine_window_exports(self) -> None:
        """Verifies AnimationEngine exports cancelAction and KINETIC_PHASES to window."""
        assert "cancelAction," in self.code
        assert "KINETIC_PHASES," in self.code


class TestCombatFeelEngine:
    """Verifies hit-stop, camera shake clamping, and damage number ring buffer in combat_feel_engine.js."""

    @pytest.fixture(autouse=True)
    def setup_code(self) -> None:
        self.code = (ENGINE_DIR / "combat_feel_engine.js").read_text(encoding="utf-8")

    def test_hit_stop_dt_safety_and_default(self) -> None:
        """Verifies isHitStopped guards against NaN / undefined dt with safe default."""
        assert "isHitStopped(dt = 0.016)" in self.code or "isHitStopped(dt=0.016)" in self.code
        assert "isNaN(dt)" in self.code
        assert "hitStopTimer -= actualDt" in self.code

    def test_hit_stop_duration_formula(self) -> None:
        """Verifies triggerHitStop computes duration = max(0.016 * frames, durationSec)."""
        assert "Math.max(0.016 * frames, durationSec)" in self.code

    def test_hit_stop_affects_both_attacker_and_target(self) -> None:
        """Verifies triggerHitStop pauses both attacker.anim and target."""
        assert "attacker.anim.hitStopTimer" in self.code
        assert "target.hitStopTimer" in self.code

    def test_screen_shake_clamping_to_specs(self) -> None:
        """Verifies screen shake intensity clamped to [2.5, 8.5] and duration to [0.12, 0.45]."""
        assert "Math.min(8.5, Math.max(2.5, intensity))" in self.code
        assert "Math.min(0.45, Math.max(0.12, durationSec))" in self.code

    def test_screen_shake_syncs_with_global(self) -> None:
        """Verifies triggerScreenShake synchronizes with global screenShake in iso_math.js / window."""
        assert "screenShake.intensity = this.screenShakeIntensity" in self.code
        assert "screenShake.duration = this.screenShakeDuration" in self.code
        assert "screenShake.timer = this.screenShakeTimer" in self.code

    def test_directional_shake_decomposition(self) -> None:
        """Verifies directional shake applies vector decomposition along strike angle."""
        assert "Math.cos(this.shakeAngle)" in self.code
        assert "Math.sin(this.shakeAngle)" in self.code
        assert "shakeOffsetX" in self.code and "shakeOffsetY" in self.code

    def test_damage_number_pop_in_bounce_scale(self) -> None:
        """Verifies pop-in bounce: standard 1.6x->1.0x (1.6 - p * 0.6), crit 2.0x->1.2x (2.0 - p * 0.8)."""
        assert "this.scale = isCrit ? 2.0 : 1.6;" in self.code
        assert "1.6" in self.code and "0.6" in self.code
        assert "2.0" in self.code and "0.8" in self.code

    def test_static_ring_buffer_pool_capacity(self) -> None:
        """Verifies pre-allocated 64-slot Ring Buffer without dynamic allocations."""
        assert "MAX_DAMAGE_NUMBERS = 64" in self.code
        assert "this.damagePool = Array.from({ length: MAX_DAMAGE_NUMBERS }" in self.code
        assert "this.poolHead = (this.poolHead + 1) % MAX_DAMAGE_NUMBERS" in self.code


class TestWeaponSwingCatalog:
    """Verifies 4 weapon archetypes in weapon_swing_catalog.js."""

    @pytest.fixture(autouse=True)
    def setup_code(self) -> None:
        self.code = (DATA_DIR / "weapon_swing_catalog.js").read_text(encoding="utf-8")

    def test_all_four_weapon_archetypes_exist(self) -> None:
        """Verifies SLASH, THRUST, SLAM, and BOLT archetypes are configured."""
        for arch in ["SLASH:", "THRUST:", "SLAM:", "BOLT:"]:
            assert arch in self.code

    def test_archetype_styles(self) -> None:
        """Verifies style arc, cone, radial, and beam for the 4 archetypes."""
        assert "style: 'arc'" in self.code
        assert "style: 'cone'" in self.code
        assert "style: 'radial'" in self.code
        assert "style: 'beam'" in self.code

    def test_archetype_hit_stop_timings(self) -> None:
        """Verifies hitStopFrames and hitStopDuration for archetypes."""
        assert "hitStopFrames: 3" in self.code and "hitStopDuration: 0.05" in self.code
        assert "hitStopFrames: 2" in self.code and "hitStopDuration: 0.035" in self.code
        assert "hitStopFrames: 4" in self.code and "hitStopDuration: 0.066" in self.code

    def test_get_archetype_lookup_and_fallback(self) -> None:
        """Verifies getWeaponSwingArchetype provides case-insensitive lookup and SLASH fallback."""
        assert "export function getWeaponSwingArchetype" in self.code
        assert "WEAPON_SWING_ARCHETYPES.SLASH" in self.code


class TestCombatSkillsAnimationCancellingAndFeel:
    """Verifies animation cancelling and visceral triggers in combat_skills.js."""

    @pytest.fixture(autouse=True)
    def setup_code(self) -> None:
        self.code = (ENGINE_DIR / "combat_skills.js").read_text(encoding="utf-8")

    def test_dodge_cancels_attack_wind_up(self) -> None:
        """Verifies doDodge immediately cancels attack wind-up and action locks."""
        assert "player.attackTimer = 0;" in self.code
        assert "cancelAction" in self.code
        assert "player.animState = 'dodge';" in self.code

    def test_dodge_charges_budget_decrement(self) -> None:
        """Verifies dodge charges decrement properly when dodging."""
        assert "dodgeCharges--;" in self.code
        assert "window.dodgeCharges = dodgeCharges;" in self.code

    def test_skills_trigger_respective_swing_archetypes(self) -> None:
        """Verifies primary attack and skills trigger SLASH, BOLT, THRUST, SLAM archetypes."""
        assert "triggerSwing('SLASH'" in self.code
        assert "triggerSwing('BOLT'" in self.code
        assert "triggerSwing('THRUST'" in self.code
        assert "triggerSwing('SLAM'" in self.code

    def test_hit_monster_triggers_hit_stop(self) -> None:
        """Verifies hitMonster triggers micro hit-stop freeze on impact."""
        assert "triggerHitStop" in self.code
        assert "player.anim.hitStopTimer" in self.code


class TestCanvasRendererWiring:
    """Verifies game loop wiring in canvas_renderer.js."""

    @pytest.fixture(autouse=True)
    def setup_code(self) -> None:
        self.code = (ENGINE_DIR / "canvas_renderer.js").read_text(encoding="utf-8")

    def test_combat_feel_and_swing_updates_in_loop(self) -> None:
        """Verifies canvas_renderer invokes combatFeelEngine.update and weaponSwingRenderer.update."""
        assert "window.combatFeelEngine.update(dt)" in self.code
        assert "window.weaponSwingRenderer.update(dt)" in self.code

    def test_weapon_swing_and_damage_numbers_rendered(self) -> None:
        """Verifies canvas_renderer renders weapon swings and damage numbers."""
        assert "window.weaponSwingRenderer.render(ctx, player)" in self.code
        assert "window.combatFeelEngine.renderDamageNumbers(ctx)" in self.code

    def test_camera_shake_uses_combat_feel_offset(self) -> None:
        """Verifies camera shake applies combat feel shake offset and executes runtime methods."""
        assert "combatFeelEngine.getShakeOffset" in self.code
        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 offset = cfe.getShakeOffset();
        console.log(JSON.stringify({
            hasMethod: typeof cfe.getShakeOffset === 'function',
            offset: offset,
            x: offset ? offset.x : null,
            y: offset ? offset.y : null
        }));
        """
        runner = f"""
        globalThis.window = globalThis;
        (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 execution failed:\n{res.stderr}"
        data = json.loads(res.stdout.strip())
        assert data["hasMethod"] is True
        assert isinstance(data["x"], (int, float))
        assert isinstance(data["y"], (int, float))


class TestCodeHygieneLineCaps:
    """Enforces strict quantitative line length limits from GEMINI.md & PROJECT.md."""

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

    def test_combat_feel_engine_line_caps(self) -> None:
        """combat_feel_engine.js: <= 350 soft cap, <= 500 hard cap."""
        lines = len((ENGINE_DIR / "combat_feel_engine.js").read_text(encoding="utf-8").splitlines())
        assert lines <= 350, f"combat_feel_engine.js has {lines} lines (exceeds 350 soft cap)"
        assert lines <= 500, f"combat_feel_engine.js has {lines} lines (exceeds 500 hard cap)"

    def test_combat_skills_line_caps(self) -> None:
        """combat_skills.js: strictly <= 500 hard cap."""
        lines = len((ENGINE_DIR / "combat_skills.js").read_text(encoding="utf-8").splitlines())
        assert lines <= 500, f"combat_skills.js has {lines} lines (exceeds 500 hard cap)"

    def test_canvas_renderer_line_caps(self) -> None:
        """canvas_renderer.js: <= 350 soft cap, <= 500 hard cap."""
        lines = len((ENGINE_DIR / "canvas_renderer.js").read_text(encoding="utf-8").splitlines())
        assert lines <= 350, f"canvas_renderer.js has {lines} lines (exceeds 350 soft cap)"
        assert lines <= 500, f"canvas_renderer.js has {lines} lines (exceeds 500 hard cap)"

    def test_weapon_swing_catalog_line_caps(self) -> None:
        """weapon_swing_catalog.js: <= 700 soft cap, <= 1000 hard cap."""
        lines = len((DATA_DIR / "weapon_swing_catalog.js").read_text(encoding="utf-8").splitlines())
        assert lines <= 700, f"weapon_swing_catalog.js has {lines} lines (exceeds 700 soft cap)"
        assert lines <= 1000, f"weapon_swing_catalog.js has {lines} lines (exceeds 1000 hard cap)"
