"""
Unit Tests for FreeExile Martial Character Ecosystem.
Validates:
1. 5 canonical character classes (Kiếm Tu, Cuồng Đao, Man Xạ Thủ, Thần Thương, Thích Khách).
2. Master 512x600 RGBA sprites and tangent-space normal maps.
3. 2.5D Isometric animation atlases, normal maps, and JSON manifests (Idle, Run, Attack, Dodge, Hurt).
4. Kinematic motion-matching metadata (8-direction headings, foot speed, 3-phase skill speed scaling).
5. Client UI bindings and weapon swap pairing definitions.
"""

import json
from pathlib import Path
import unittest
from PIL import Image

REPO_ROOT = Path(__file__).resolve().parent.parent.parent
CHAR_ASSETS_DIR = REPO_ROOT / "client" / "webapp" / "assets" / "characters"
ANIM_ASSETS_DIR = REPO_ROOT / "client" / "webapp" / "assets" / "animations"
WEBAPP_DIR = REPO_ROOT / "client" / "webapp"

CLASSES = [
    "char_sword_master",
    "char_sword_maiden",
    "char_feral_berserker",
    "char_wild_archer",
    "char_glacial_lancer",
    "char_shadow_assassin"
]

CLIPS = ["idle", "run", "attack_slash", "dodge", "hurt"]


class TestMartialCharacterEcosystem(unittest.TestCase):
    def test_01_master_character_sprites_and_normal_maps_exist(self) -> None:
        """Verify all 5 master character sprites & normal maps exist with valid format."""
        for class_id in CLASSES:
            sprite_path = CHAR_ASSETS_DIR / f"{class_id}.png"
            normal_path = CHAR_ASSETS_DIR / f"{class_id}_normal.png"
            
            self.assertTrue(sprite_path.is_file(), f"Missing sprite for {class_id}: {sprite_path}")
            self.assertTrue(normal_path.is_file(), f"Missing normal map for {class_id}: {normal_path}")

            with Image.open(sprite_path) as img:
                self.assertEqual(img.mode, "RGBA")
                self.assertGreaterEqual(img.width, 400)
                self.assertGreaterEqual(img.height, 500)

            with Image.open(normal_path) as n_img:
                self.assertEqual(n_img.mode, "RGBA")
                self.assertEqual(n_img.size, (img.width, img.height))

    def test_02_animation_atlases_and_manifests_exist(self) -> None:
        """Verify 5 character animation atlases, normal maps, and manifests exist."""
        for class_id in CLASSES:
            atlas_path = ANIM_ASSETS_DIR / f"{class_id}_anim_atlas.png"
            normal_path = ANIM_ASSETS_DIR / f"{class_id}_anim_atlas_normal.png"
            manifest_path = ANIM_ASSETS_DIR / f"{class_id}_anim_manifest.json"

            self.assertTrue(atlas_path.is_file(), f"Missing atlas for {class_id}")
            self.assertTrue(normal_path.is_file(), f"Missing normal atlas for {class_id}")
            self.assertTrue(manifest_path.is_file(), f"Missing manifest for {class_id}")

            with Image.open(atlas_path) as atlas:
                self.assertEqual(atlas.mode, "RGBA")
                self.assertEqual(atlas.size, (1280, 960))

    def test_03_animation_manifest_kinematics_and_clips(self) -> None:
        """Verify animation manifests contain all required clips and motion-matching metadata."""
        for class_id in CLASSES:
            manifest_path = ANIM_ASSETS_DIR / f"{class_id}_anim_manifest.json"
            with open(manifest_path, "r", encoding="utf-8") as f:
                manifest = json.load(f)

            self.assertEqual(manifest["frame_width"], 160)
            self.assertEqual(manifest["frame_height"], 192)
            self.assertEqual(manifest["texture_width"], 1280)
            self.assertEqual(manifest["texture_height"], 960)

            # Check animations / clips
            self.assertIn("animations", manifest)
            animations = manifest["animations"]
            for clip_name in CLIPS:
                self.assertIn(clip_name, animations, f"Missing clip {clip_name} in {class_id}")
                clip = animations[clip_name]
                self.assertGreater(clip["fps"], 0)
                self.assertGreater(len(clip["frames"]), 0)

            # Check kinematics & motion matching
            kinematics = manifest.get("kinematics", {})
            self.assertIn("base_move_speed", kinematics)
            self.assertGreater(kinematics["base_move_speed"], 0)
            self.assertIn("stride_length_world", kinematics)
            self.assertGreater(kinematics["stride_length_world"], 0)
            self.assertIn("foot_speed_sync_formula", kinematics)

    def test_04_html_class_cards_binding(self) -> None:
        """Verify modal-char-create in index.html defines all 5 character class cards."""
        html_path = WEBAPP_DIR / "index.html"
        self.assertTrue(html_path.is_file())
        html_content = html_path.read_text(encoding="utf-8")
        char_create_tpl = WEBAPP_DIR / "templates" / "char_create_modal.html"
        if char_create_tpl.exists():
            html_content += "\n" + char_create_tpl.read_text(encoding="utf-8")

        for class_id in CLASSES:
            self.assertIn(f'data-class="{class_id}"', html_content, f"Missing card for {class_id} in index.html or templates")

    def test_05_client_scripts_class_support(self) -> None:
        """Verify char_creation.js, iso_math.js, and animation_engine.js support all 5 classes."""
        char_create_js = (WEBAPP_DIR / "js" / "ui" / "char_creation.js").read_text(encoding="utf-8")
        iso_math_js = (WEBAPP_DIR / "js" / "engine" / "iso_math.js").read_text(encoding="utf-8")
        anim_engine_js = (WEBAPP_DIR / "js" / "engine" / "animation_engine.js").read_text(encoding="utf-8")
        props_catalog_js = (WEBAPP_DIR / "js" / "data" / "sanctuary_props_catalog.js").read_text(encoding="utf-8")

        for class_id in CLASSES:
            self.assertIn(class_id, char_create_js, f"Missing {class_id} in char_creation.js")
            self.assertTrue(
                class_id in iso_math_js or class_id in props_catalog_js,
                f"Missing {class_id} in WEAPON_SWAP_PAIRS"
            )
            self.assertIn(class_id, anim_engine_js, f"Missing {class_id} in animation_engine.js")


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