"""
FreeExile PBR Power-of-Two (PoT) Texture Atlas VFX & Cocos 3.8.x / Apple Metal Pipeline E2E Test Suite.

Authoritative Specifications:
- ORIGINAL_REQUEST.md (## 2026-10-04T12:05:22Z)
- PROJECT.md (orchestrator_22 Architecture & Interface Contracts)
- FE-SURVEY-SKILLS-VFX-20261004 (explorer_survey_1 Skills & Sigils Specification)
- SURVEY-20261004-ASSET-PIPELINE-TEXTURES (explorer_survey_3 Texture Packing & Normal Maps)

4-Tier Testing Methodology:
- Tier 1: Feature Coverage (12 Active Skills + 5 Support Sigils, PoT 1024/2048, Albedo+Normal, ASTC, Scaffolding)
- Tier 2: Boundary & Corner Cases (Dilation Padding fringe, Normal Blue mean > 128.0, UV bounds [0, 1], Pivot [0.5, 0.90])
- Tier 3: Cross-Feature Combinations (Skill + Sigil Pairwise Sequence, Normal Unit Length |N|=1.0)
- Tier 4: Real-World Workload Scenarios (Combat Loadout Q/W/E/Space Rotation Simulation, Cocos TypeScript Compilation)
"""

from __future__ import annotations

import json
from pathlib import Path
import subprocess
from typing import Any, Dict, List, Tuple

import numpy as np
from PIL import Image, ImageStat
import pytest

from tests.e2e_cocos.vfx_test_helpers import (
    REPO_ROOT,
    COCOS_VFX_DIR,
    COCOS_MANIFEST_PATH,
    COCOS_ALBEDO_PATH,
    COCOS_NORMAL_PATH,
    COCOS_ASTC_PATH,
    COCOS_MONSTERS_DIR,
    COCOS_CHARACTERS_DIR,
    CANONICAL_ACTIVE_SKILLS,
    CANONICAL_MOBILITY_SKILL,
    CANONICAL_SUPPORT_SIGILS,
    CANONICAL_MONSTER_GENERA,
    CANONICAL_EXILE_CLASSES,
    match_clip_identifier,
    validate_manifest_uv_bounds,
    loaded_manifest,
    loaded_albedo_image,
    loaded_normal_image,
)


# ==============================================================================
# TIER 1: FEATURE COVERAGE (Functional Feature Coverage)
# ==============================================================================


class TestTier1FeatureCoverage:
    """
    Tier 1: Category-Partition Feature Coverage.
    Verifies that all 12 active skills + 5 support sigils, PoT dimensions,
    Albedo + Normal maps, ASTC mobile container, and scaffolding exist.
    """

    def test_t1_all_twelve_active_skills_present_in_manifest(self, loaded_manifest: Dict[str, Any]) -> None:
        """
        Feature 1: 12 Active Skills High-Fidelity VFX.
        Verifies all 12 active skills (1001-1012) are defined in the manifest clips.
        """
        clips = loaded_manifest.get("clips", {})
        clip_names = list(clips.keys())
        missing_skills = []

        for skill in CANONICAL_ACTIVE_SKILLS:
            found = any(match_clip_identifier(c, skill) for c in clip_names)
            if not found:
                missing_skills.append(f"{skill['id']} ({skill['key']})")

        assert len(missing_skills) == 0, (
            f"Active Skills missing from VFX manifest ({len(missing_skills)}/12): {missing_skills}. "
            f"Present clips: {clip_names}"
        )

    def test_t1_all_five_support_sigils_present_in_manifest(self, loaded_manifest: Dict[str, Any]) -> None:
        """
        Feature 2: 5 Support Sigils High-Fidelity VFX.
        Verifies all 5 augment sigils (2001, 2002, 2004, 2005, 2006/2003) exist in manifest clips.
        """
        clips = loaded_manifest.get("clips", {})
        clip_names = list(clips.keys())
        missing_sigils = []

        for sigil in CANONICAL_SUPPORT_SIGILS:
            found = any(match_clip_identifier(c, sigil) for c in clip_names)
            if not found:
                missing_sigils.append(f"{sigil['id']} ({sigil['key']})")

        assert len(missing_sigils) == 0, (
            f"Support Sigils missing from VFX manifest ({len(missing_sigils)}/5): {missing_sigils}. "
            f"Present clips: {clip_names}"
        )

    def test_t1_atlas_dimensions_power_of_two(
        self, loaded_albedo_image: Image.Image, loaded_normal_image: Image.Image
    ) -> None:
        """
        Feature 3: Power-of-Two (PoT) Texture Packing.
        Dimensions must be 1024x1024 or 2048x2048 to prevent GPU sampling penalties and allow ASTC compression.
        """
        valid_pot_dimensions = {1024, 2048}

        w_alb, h_alb = loaded_albedo_image.size
        assert w_alb in valid_pot_dimensions and h_alb in valid_pot_dimensions, (
            f"Albedo texture dimensions ({w_alb}x{h_alb}) must be Power-of-Two (1024 or 2048)"
        )
        assert w_alb == h_alb, f"Atlas should be square, got {w_alb}x{h_alb}"

        w_nrm, h_nrm = loaded_normal_image.size
        assert w_nrm in valid_pot_dimensions and h_nrm in valid_pot_dimensions, (
            f"Normal map dimensions ({w_nrm}x{h_nrm}) must be Power-of-Two (1024 or 2048)"
        )
        assert (w_alb, h_alb) == (w_nrm, h_nrm), (
            f"Albedo ({w_alb}x{h_alb}) and Normal ({w_nrm}x{h_nrm}) dimensions must match identically"
        )

    def test_t1_albedo_and_tangent_normal_maps_exist(self) -> None:
        """
        Feature 4 & 7: Dual PBR Textures in Cocos Resources.
        Both Albedo (`*_atlas.png`) and Normal (`*_atlas_normal.png`) files must exist and be non-empty.
        """
        assert COCOS_ALBEDO_PATH.exists(), f"Albedo atlas missing at {COCOS_ALBEDO_PATH}"
        assert COCOS_ALBEDO_PATH.stat().st_size > 1024, f"Albedo atlas file too small: {COCOS_ALBEDO_PATH.stat().st_size} bytes"

        assert COCOS_NORMAL_PATH.exists(), f"Normal map atlas missing at {COCOS_NORMAL_PATH}"
        assert COCOS_NORMAL_PATH.stat().st_size > 1024, f"Normal map file too small: {COCOS_NORMAL_PATH.stat().st_size} bytes"

    def test_t1_astc_texture_container_generation_and_header(self) -> None:
        """
        Feature 8: ASTC 4x4 Mobile Compression Container.
        Verifies that `.astc` file exists and starts with the canonical 16-byte header:
        Magic: 0x5CA1AB13, blockdim_x=4, blockdim_y=4, blockdim_z=1.
        """
        assert COCOS_ASTC_PATH.exists(), (
            f"ASTC container not found at {COCOS_ASTC_PATH}. Apple Metal requires ASTC 4x4 binary."
        )
        file_size = COCOS_ASTC_PATH.stat().st_size
        assert file_size >= 16, f"ASTC file too small ({file_size} bytes)"

        with open(COCOS_ASTC_PATH, "rb") as f:
            header = f.read(16)

        expected_magic = bytes([0x13, 0xAB, 0xA1, 0x5C])
        assert header[:4] == expected_magic, (
            f"Invalid ASTC magic bytes: expected 0x5CA1AB13, got {header[:4].hex()}"
        )
        block_x = header[4]
        block_y = header[5]
        block_z = header[6]
        assert (block_x, block_y, block_z) == (4, 4, 1), (
            f"Expected ASTC 4x4x1 block footprint, got {block_x}x{block_y}x{block_z}"
        )

        w = int.from_bytes(header[7:10], byteorder="little")
        h = int.from_bytes(header[10:13], byteorder="little")
        assert w in (1024, 2048) and h in (1024, 2048), (
            f"ASTC header width/height must be 1024 or 2048, got {w}x{h}"
        )

    def test_t1_monster_and_character_scaffolding_structure(self) -> None:
        """
        Feature 9: Monster & Character Pipeline Scaffolding.
        Verifies extensible directory templates exist for 10 Monster archetypes and 6 Exile classes,
        and ensures all 16 manifests satisfy 0.0 <= u0, v0, u1, v1 <= 1.0.
        """
        scaffold_script_candidates = [
            REPO_ROOT / "tools" / "asset_pipeline" / "monster_character_pipeline_scaffold.py",
            REPO_ROOT / "tools" / "asset_pipeline" / "generate_monster_and_character_pipelines.py",
        ]
        assert any(p.exists() for p in scaffold_script_candidates), "Scaffolding script missing in tools/asset_pipeline/"

        assert COCOS_MONSTERS_DIR.exists(), f"Monsters directory missing: {COCOS_MONSTERS_DIR}"
        assert COCOS_CHARACTERS_DIR.exists(), f"Characters directory missing: {COCOS_CHARACTERS_DIR}"

        for genus in CANONICAL_MONSTER_GENERA:
            manifest_path = COCOS_MONSTERS_DIR / genus / f"{genus}_anim_manifest.json"
            assert manifest_path.exists(), f"Monster manifest missing: {manifest_path}"
            errors = validate_manifest_uv_bounds(manifest_path)
            assert not errors, f"Monster {genus} UV out of bounds: {errors[:3]}"

        for class_id in CANONICAL_EXILE_CLASSES:
            manifest_path = COCOS_CHARACTERS_DIR / class_id / f"{class_id}_anim_manifest.json"
            assert manifest_path.exists(), f"Character manifest missing: {manifest_path}"
            errors = validate_manifest_uv_bounds(manifest_path)
            assert not errors, f"Character {class_id} UV out of bounds: {errors[:3]}"


# ==============================================================================
# TIER 2: BOUNDARY & CORNER CASES (Boundary Value Analysis)
# ==============================================================================


class TestTier2BoundaryAndCornerCases:
    """
    Tier 2: Boundary Value Analysis.
    Verifies edge dilation color padding, strict Blue channel mean > 128.0,
    normalized UV unit interval bounds [0.0, 1.0], and anchor bottom-center pivot [0.5, 0.90].
    """

    def test_t2_edge_texels_dilation_padding_nonzero_rgb_with_zero_alpha(
        self, loaded_albedo_image: Image.Image
    ) -> None:
        """
        Corner Case 1: Dilation Padding Non-Zero RGB on Transparent Boundary Fringe.
        Checks morphological dilation padding around alpha edges to prevent dark halo artifacts.
        """
        rgba = np.array(loaded_albedo_image.convert("RGBA"))
        alpha = rgba[:, :, 3]

        transparent_mask = alpha == 0
        r, g, b = rgba[:, :, 0], rgba[:, :, 1], rgba[:, :, 2]
        color_present_mask = (r > 0) | (g > 0) | (b > 0)

        dilated_bleed_pixels = np.logical_and(transparent_mask, color_present_mask)
        count_dilated = int(np.sum(dilated_bleed_pixels))

        assert count_dilated > 0, (
            "Dilation padding missing: Zero colored pixels found in transparent areas (alpha == 0). "
            "Texture edges will suffer from dark halo bleeding on black backgrounds."
        )

    def test_t2_tangent_normal_map_blue_channel_mean_above_threshold(
        self, loaded_normal_image: Image.Image
    ) -> None:
        """
        Corner Case 2: Tangent-Space Normal Map Blue Channel Dominance (Nz >= 0).
        Strict invariant: The mean value of the Blue channel across the texture must be > 128.0.
        """
        stats = ImageStat.Stat(loaded_normal_image)
        b_mean = stats.mean[2]

        assert b_mean > 128.0, (
            f"Tangent Normal map Blue channel mean ({b_mean:.2f}) must be > 128.0. "
            "A value <= 128 indicates incorrect BGR channel ordering or inverted Nz component."
        )

    def test_t2_uv_coordinates_bounded_within_unit_interval(self, loaded_manifest: Dict[str, Any]) -> None:
        """
        Corner Case 3: Normalized UV Coordinates Strictly in [0.0, 1.0].
        Verifies that all frame rects in savage_primal_skills_vfx_atlas satisfy 0.0 <= u, v <= 1.0.
        """
        tex_w = float(loaded_manifest.get("textureWidth", 2048))
        tex_h = float(loaded_manifest.get("textureHeight", 2048))
        clips = loaded_manifest.get("clips", {})

        for clip_name, clip_data in clips.items():
            frames = clip_data.get("frames", [])
            for idx, frame in enumerate(frames):
                x = float(frame["x"])
                y = float(frame["y"])
                w = float(frame["w"])
                h = float(frame["h"])

                u0 = x / tex_w
                v0 = y / tex_h
                u1 = (x + w) / tex_w
                v1 = (y + h) / tex_h

                assert 0.0 <= u0 <= 1.0 and 0.0 <= u1 <= 1.0, (
                    f"Clip '{clip_name}' frame {idx} U coordinate out of bounds: [{u0:.4f}, {u1:.4f}]"
                )
                assert 0.0 <= v0 <= 1.0 and 0.0 <= v1 <= 1.0, (
                    f"Clip '{clip_name}' frame {idx} V coordinate out of bounds: [{v0:.4f}, {v1:.4f}]"
                )
                assert u1 > u0 and v1 > v0, (
                    f"Clip '{clip_name}' frame {idx} has degenerate rect dimensions: w={w}, h={h}"
                )

    def test_t2_frame_dimensions_match_atlas_grid_metrics(self, loaded_manifest: Dict[str, Any]) -> None:
        """
        Corner Case 4: Frame Width and Height Uniformity across Clips.
        Ensures frame rects match declared manifest frame dimensions.
        """
        declared_fw = loaded_manifest.get("frameWidth", 128)
        declared_fh = loaded_manifest.get("frameHeight", 128)

        clips = loaded_manifest.get("clips", {})
        for clip_name, clip_data in clips.items():
            for frame in clip_data.get("frames", []):
                assert frame.get("w") == declared_fw, (
                    f"Frame width mismatch in clip '{clip_name}': expected {declared_fw}, got {frame.get('w')}"
                )
                assert frame.get("h") == declared_fh, (
                    f"Frame height mismatch in clip '{clip_name}': expected {declared_fh}, got {frame.get('h')}"
                )

    def test_t2_bottom_center_pivot_coordinate_precision(self, loaded_manifest: Dict[str, Any]) -> None:
        """
        Corner Case 5: Bottom-Center Anchor Pivot Precision [0.5, 0.90].
        Ensures ARPG isometric grounding anchor is [0.5, 0.90] with ±0.001 precision.
        """
        default_pivot = loaded_manifest.get("defaultPivot", [0.5, 0.90])
        assert abs(default_pivot[0] - 0.5) < 0.001 and abs(default_pivot[1] - 0.90) < 0.001, (
            f"Invalid defaultPivot in manifest: expected [0.5, 0.90], got {default_pivot}"
        )

        clips = loaded_manifest.get("clips", {})
        for clip_name, clip_data in clips.items():
            for idx, frame in enumerate(clip_data.get("frames", [])):
                pivot = frame.get("pivot", default_pivot)
                assert abs(pivot[0] - 0.5) < 0.001 and abs(pivot[1] - 0.90) < 0.001, (
                    f"Invalid pivot in clip '{clip_name}' frame {idx}: expected [0.5, 0.90], got {pivot}"
                )


# ==============================================================================
# TIER 3: CROSS-FEATURE COMBINATIONS (Pairwise Combinations)
# ==============================================================================


class TestTier3CrossFeatureCombinations:
    """
    Tier 3: Cross-Feature State Interleaving.
    Verifies Active Skill + Support Sigil animation sequence compatibility
    and normal map vector unit length normalization.
    """

    @pytest.mark.parametrize(
        "skill_id,sigil_id",
        [
            (1001, 2001),  # Thunder Sword + Multi-Projectile
            (1002, 2005),  # Wind Fan + Chain Lightning
            (1007, 2004),  # Whirlwind + Blood Magic
            (1009, 2002),  # Ground Slam + AoE Expansion
            (1003, 2006),  # Poison Needle + Freeze Sigil
        ],
    )
    def test_t3_active_skill_and_support_sigil_sequence_compatibility(
        self, loaded_manifest: Dict[str, Any], skill_id: int, sigil_id: int
    ) -> None:
        """
        Pairwise Check 1: Active Skill + Support Sigil Augmentation Compatibility.
        When a martial skill is linked to a support sigil, their animation frame sequences,
        playback fps, and frame dimensions must be compatible for simultaneous playback.
        """
        clips = loaded_manifest.get("clips", {})
        skill_clip = None
        sigil_clip = None

        skill_def = next((s for s in CANONICAL_ACTIVE_SKILLS if s["id"] == skill_id), None)
        sigil_def = next((s for s in CANONICAL_SUPPORT_SIGILS if s["id"] == sigil_id), None)

        assert skill_def is not None and sigil_def is not None

        for c_name, c_data in clips.items():
            if match_clip_identifier(c_name, skill_def) and skill_clip is None:
                skill_clip = c_data
            if match_clip_identifier(c_name, sigil_def) and sigil_clip is None:
                sigil_clip = c_data

        assert skill_clip is not None, f"Skill {skill_id} clip missing from manifest"
        assert sigil_clip is not None, f"Support Sigil {sigil_id} clip missing from manifest"

        skill_frames = len(skill_clip.get("frames", []))
        sigil_frames = len(sigil_clip.get("frames", []))

        assert skill_frames >= 4, f"Skill {skill_id} has insufficient frames: {skill_frames}"
        assert sigil_frames >= 4, f"Sigil {sigil_id} has insufficient frames: {sigil_frames}"

        skill_fps = skill_clip.get("fps", 16)
        sigil_fps = sigil_clip.get("fps", 16)
        assert abs(skill_fps - sigil_fps) <= 6, f"FPS mismatch between Skill ({skill_fps}) and Sigil ({sigil_fps})"

    def test_t3_normal_map_tangent_vector_unit_length_normalization(
        self, loaded_normal_image: Image.Image
    ) -> None:
        """
        Pairwise Check 2: Tangent Vector Unit Length Invariant (|N| = 1.0).
        For all non-transparent texels, the unpacked normal vector must have unit length:
        |N| = sqrt(Nx^2 + Ny^2 + Nz^2) ≈ 1.0 within 8-bit quantization tolerance (±0.08).
        """
        rgba = np.array(loaded_normal_image.convert("RGBA"))
        alpha = rgba[:, :, 3]
        opaque_mask = alpha > 15

        if not np.any(opaque_mask):
            pytest.skip("No opaque pixels in normal map to test vector length")

        rgb_opaque = rgba[:, :, :3][opaque_mask].astype(np.float32)

        # Unpack [0, 255] to [-1.0, 1.0]
        nx = (rgb_opaque[:, 0] / 255.0) * 2.0 - 1.0
        ny = (rgb_opaque[:, 1] / 255.0) * 2.0 - 1.0
        nz = (rgb_opaque[:, 2] / 255.0) * 2.0 - 1.0

        lengths = np.sqrt(nx * nx + ny * ny + nz * nz)
        mean_length = float(np.mean(lengths))

        assert 0.92 <= mean_length <= 1.08, (
            f"Normal vector mean length is {mean_length:.3f}, expected 1.0 ± 0.08. "
            "Vectors are unnormalized, which causes lighting distortion."
        )


# ==============================================================================
# TIER 4: REAL-WORLD WORKLOAD SCENARIOS (Workload & Build Verification)
# ==============================================================================


class TestTier4RealWorldWorkloadScenarios:
    """
    Tier 4: Real-World Workload Scenarios.
    Simulates high-intensity combat rotation Q/W/E/Space at 120 FPS,
    and runs the Cocos Creator TypeScript compilation check.
    """

    def test_t4_full_combat_loadout_sequence_simulation(self, loaded_manifest: Dict[str, Any]) -> None:
        """
        Workload Scenario 1: Full Combat Rotation Simulation (Q / W / E / Space).
        Simulates player executing rotation skills and verifies duration and frame integrity.
        """
        clips = loaded_manifest.get("clips", {})
        rotation_skills = [
            ("Q", CANONICAL_ACTIVE_SKILLS[0]),  # 1001
            ("W", CANONICAL_ACTIVE_SKILLS[6]),  # 1007
            ("E", CANONICAL_ACTIVE_SKILLS[8]),  # 1009
            ("Space", CANONICAL_MOBILITY_SKILL),  # 1099
        ]

        total_simulated_duration_ms = 0

        for key, defn in rotation_skills:
            matched_clip = None
            for c_name, c_data in clips.items():
                if match_clip_identifier(c_name, defn):
                    matched_clip = c_data
                    break

            assert matched_clip is not None, (
                f"Combat rotation hotkey [{key}] skill '{defn['key']}' (ID {defn['id']}) missing from manifest!"
            )
            frames = matched_clip.get("frames", [])
            assert len(frames) >= 4, f"Clip for [{key}] has fewer than 4 frames"

            fps = matched_clip.get("fps", 16)
            duration_ms = (len(frames) / float(fps)) * 1000.0
            total_simulated_duration_ms += duration_ms

        assert total_simulated_duration_ms > 800.0, (
            f"Full rotation duration too short: {total_simulated_duration_ms:.1f}ms"
        )

    def test_t4_cocos_creator_typescript_compilation(self) -> None:
        """
        Workload Scenario 2: Cocos Creator TypeScript Compilation Gate.
        Executes `npm run build:web` (`tsc --noEmit`) in client/cocos/.
        Must terminate with exit code 0 and zero TypeScript errors.
        """
        cocos_dir = REPO_ROOT / "client" / "cocos"
        assert cocos_dir.exists(), f"Cocos client directory not found at {cocos_dir}"

        result = subprocess.run(
            ["npm", "run", "build:web"],
            cwd=str(cocos_dir),
            shell=True,
            capture_output=True,
            text=True,
            timeout=30,
        )

        assert result.returncode == 0, (
            f"Cocos TypeScript compilation failed (exit code {result.returncode})!\n"
            f"STDOUT:\n{result.stdout}\n"
            f"STDERR:\n{result.stderr}"
        )
