"""
Unit Tests for FreeExile 2.5D Isometric Animation Pipeline and Motion-Matching Kinematics.
Tests stride-length foot-speed synchronization, 8-directional heading math, and manifest integrity.
"""

from __future__ import annotations

import json
import math
from pathlib import Path
import unittest

from tools.asset_pipeline.animation_pipeline import (
    calculate_action_duration,
    calculate_stride_fps,
    resolve_heading_8dir,
)

WORKSPACE_ROOT = Path(__file__).resolve().parent.parent.parent
ANIMATIONS_DIR = WORKSPACE_ROOT / "client" / "webapp" / "assets" / "animations"


class TestAnimationPipelineAndMotionMatching(unittest.TestCase):
    """Test suite for kinematic motion-matching formulas and baked animation manifests."""

    def test_foot_speed_synchronization_fps(self) -> None:
        """Verifies that frame rate scales precisely with velocity to eliminate foot sliding."""
        base_speed = 4.2
        base_fps = 12.0

        # When stopped, stride fps is 0
        self.assertEqual(calculate_stride_fps(0.0, base_speed, base_fps), 0.0)

        # Half speed movement (slow walk) -> exactly half fps
        walk_fps = calculate_stride_fps(2.1, base_speed, base_fps)
        self.assertAlmostEqual(walk_fps, 6.0, places=2)

        # Standard running speed -> exactly base fps
        run_fps = calculate_stride_fps(4.2, base_speed, base_fps)
        self.assertAlmostEqual(run_fps, 12.0, places=2)

        # High speed sprint (1.75x boost with i-frame dodge roll) -> 21.0 fps
        sprint_fps = calculate_stride_fps(7.35, base_speed, base_fps)
        self.assertAlmostEqual(sprint_fps, 21.0, places=2)

    def test_action_speed_scaling(self) -> None:
        """Verifies skill cast duration scales inversely with action speed stat."""
        base_duration = 0.60

        # Baseline 1.0 action speed
        self.assertAlmostEqual(calculate_action_duration(base_duration, 1.0), 0.60, places=3)

        # 1.5x Attack Speed (Frenzy / Dual Daggers) -> 0.40s duration
        self.assertAlmostEqual(calculate_action_duration(base_duration, 1.5), 0.40, places=3)

        # 2.0x Double speed -> 0.30s duration
        self.assertAlmostEqual(calculate_action_duration(base_duration, 2.0), 0.30, places=3)

        # Clamping check: very slow action speed does not divide by zero
        self.assertLess(calculate_action_duration(base_duration, 0.05), 5.0)

    def test_8_directional_isometric_heading_resolver(self) -> None:
        """Verifies 360-degree angle mapping to 8 isometric compass facings."""
        self.assertEqual(resolve_heading_8dir(0.0), "E")
        self.assertEqual(resolve_heading_8dir(45.0), "SE")
        self.assertEqual(resolve_heading_8dir(90.0), "S")
        self.assertEqual(resolve_heading_8dir(135.0), "SW")
        self.assertEqual(resolve_heading_8dir(180.0), "W")
        self.assertEqual(resolve_heading_8dir(225.0), "NW")
        self.assertEqual(resolve_heading_8dir(270.0), "N")
        self.assertEqual(resolve_heading_8dir(315.0), "NE")
        # Wrapped & negative degrees
        self.assertEqual(resolve_heading_8dir(360.0), "E")
        self.assertEqual(resolve_heading_8dir(-90.0), "N")
        self.assertEqual(resolve_heading_8dir(720.0), "E")

    def test_hero_animation_manifest_integrity(self) -> None:
        """Verifies baked hero animation manifest structure, required clips, and frame dimensions."""
        hero_manifest_path = ANIMATIONS_DIR / "hero_anim_manifest.json"
        self.assertTrue(hero_manifest_path.exists(), "Hero manifest file must exist on disk")

        with open(hero_manifest_path, "r", encoding="utf-8") as f:
            data = json.load(f)

        self.assertEqual(data["entity_id"], "hero")
        self.assertGreater(data["texture_width"], 0)
        self.assertGreater(data["texture_height"], 0)

        # Required action clips for hero
        required_clips = ["idle", "run", "attack_slash", "skill_whirlwind", "dodge", "hurt"]
        for clip_name in required_clips:
            self.assertIn(clip_name, data["animations"])
            clip = data["animations"][clip_name]
            self.assertGreater(len(clip["frames"]), 0)
            self.assertGreater(clip["fps"], 0)

        # Footstep events on run
        run_clip = data["animations"]["run"]
        self.assertIn("footsteps", run_clip)
        self.assertEqual(run_clip["footsteps"], [0, 4])

        # Attack slash must define hit_frame
        attack_clip = data["animations"]["attack_slash"]
        self.assertEqual(attack_clip["hit_frame"], 2)

    def test_monster_animation_manifests(self) -> None:
        """Verifies monster animation manifests for hellhound and skeleton warrior."""
        for mob_id in ["mob_feral_hellhound", "mob_skeleton_warrior"]:
            m_path = ANIMATIONS_DIR / f"{mob_id}_anim_manifest.json"
            self.assertTrue(m_path.exists(), f"Monster manifest for {mob_id} must exist")

            with open(m_path, "r", encoding="utf-8") as f:
                m_data = json.load(f)

            self.assertEqual(m_data["entity_id"], mob_id)
            required_mob_clips = ["idle", "run", "attack", "hurt", "death"]
            for clip_name in required_mob_clips:
                self.assertIn(clip_name, m_data["animations"])

            # Verify associated texture files exist
            atlas_png = ANIMATIONS_DIR / f"{mob_id}_anim_atlas.png"
            normal_png = ANIMATIONS_DIR / f"{mob_id}_anim_atlas_normal.png"
            self.assertTrue(atlas_png.exists())
            self.assertTrue(normal_png.exists())


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