"""
Adversarial Stress Test Suite for FreeExile 30 Map Styles Assets.
Verifies decompression throughput, SLA latencies, concurrent thread safety,
memory footprint constraints (< 500 KB active biome, < 8 MB all 30 styles),
and resilience against corrupt, zero-byte, truncated, and malicious files.
"""

from __future__ import annotations

import concurrent.futures
import io
import struct
import time
import zlib
from pathlib import Path
from typing import Dict, List, Tuple

import pytest
from PIL import Image, ImageFile

from server.world.map_style_catalog import list_available_styles

# Prevent PIL from throwing IOError on truncated images unless requested
ImageFile.LOAD_TRUNCATED_IMAGES = False

PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent
STYLES_ROOT = PROJECT_ROOT / "client" / "webapp" / "assets" / "map" / "styles"

CORE_ASSET_NAMES: Tuple[str, ...] = (
    "floor.png",
    "floor_normal.png",
    "wall.png",
    "wall_normal.png",
    "path.png",
    "path_normal.png",
    "liquid.png",
    "liquid_normal.png",
    "props.png",
)


def _load_and_decompress(file_path: Path) -> Tuple[int, int, float]:
    """Decompresses image file and returns (width, height, elapsed_ms)."""
    t0 = time.perf_counter()
    with Image.open(file_path) as img:
        img.load()
        w, h = img.width, img.height
    elapsed_ms = (time.perf_counter() - t0) * 1000.0
    return w, h, elapsed_ms


class TestAssetDecompressionAndThroughputStress:
    """Stress-tests decompression latencies, throughput, and concurrent access."""

    def test_all_360_assets_decompression_sla(self) -> None:
        """Every single asset must decompress within p99 < 25.0ms SLA."""
        latencies: List[float] = []
        styles = list_available_styles()
        assert len(styles) == 30

        for s_id in styles:
            s_dir = STYLES_ROOT / s_id.lower()
            for fname in CORE_ASSET_NAMES:
                _, _, elapsed = _load_and_decompress(s_dir / fname)
                latencies.append(elapsed)
            for p_file in (s_dir / "props").glob("*.png"):
                _, _, elapsed = _load_and_decompress(p_file)
                latencies.append(elapsed)

        assert len(latencies) == 360
        max_lat = max(latencies)
        mean_lat = sum(latencies) / len(latencies)
        sorted_lat = sorted(latencies)
        p95_lat = sorted_lat[int(len(sorted_lat) * 0.95)]
        p99_lat = sorted_lat[int(len(sorted_lat) * 0.99)]

        assert max_lat < 25.0, f"Max decompression latency {max_lat:.2f}ms >= 25ms"
        assert p99_lat < 5.0, f"P99 latency {p99_lat:.2f}ms >= 5ms"
        assert p95_lat < 2.0, f"P95 latency {p95_lat:.2f}ms >= 2ms"
        assert mean_lat < 1.0, f"Mean latency {mean_lat:.2f}ms >= 1ms"

    def test_rapid_biome_switching_stress(self) -> None:
        """Simulates rapid player traversal across 30 biomes over 3 cycles."""
        styles = list_available_styles()
        total_biome_times: List[float] = []

        for _ in range(3):
            for s_id in styles:
                s_dir = STYLES_ROOT / s_id.lower()
                t0 = time.perf_counter()
                for fname in CORE_ASSET_NAMES:
                    with Image.open(s_dir / fname) as img:
                        img.load()
                biome_time_ms = (time.perf_counter() - t0) * 1000.0
                total_biome_times.append(biome_time_ms)

        assert len(total_biome_times) == 90
        avg_switch_time = sum(total_biome_times) / len(total_biome_times)
        max_switch_time = max(total_biome_times)
        assert avg_switch_time < 15.0, f"Average switch {avg_switch_time:.2f}ms >= 15ms"
        assert max_switch_time < 50.0, f"Max switch {max_switch_time:.2f}ms >= 50ms"

    def test_concurrent_multi_threaded_decompression(self) -> None:
        """20 concurrent threads decompressing textures simultaneously."""
        styles = list_available_styles()
        tasks: List[Path] = []
        for s_id in styles:
            s_dir = STYLES_ROOT / s_id.lower()
            tasks.extend(s_dir / f for f in CORE_ASSET_NAMES)

        def worker(path: Path) -> bool:
            with Image.open(path) as img:
                img.load()
                return img.width > 0 and img.height > 0

        with concurrent.futures.ThreadPoolExecutor(max_workers=20) as executor:
            futures = [executor.submit(worker, p) for p in tasks]
            results = [f.result() for f in concurrent.futures.as_completed(futures)]

        assert len(results) == len(tasks)
        assert all(results)


class TestAdversarialFileCorruptionAndEdgeCases:
    """Stress-tests behavior under corrupted, zero-byte, truncated, and malicious files."""

    def test_zero_byte_asset_rejection(self, tmp_path: Path) -> None:
        """Zero-byte file must raise UnidentifiedImageError or EOFError."""
        zero_file = tmp_path / "zero.png"
        zero_file.touch()
        with pytest.raises((Exception,)):
            with Image.open(zero_file) as img:
                img.load()

    @pytest.mark.parametrize("length", [1, 4, 8, 16, 24])
    def test_truncated_stream_rejection(self, tmp_path: Path, length: int) -> None:
        """Truncated byte streams must be detected and rejected gracefully."""
        sample_path = STYLES_ROOT / "sty_01_hoang_mang_co_lo" / "floor.png"
        raw_data = sample_path.read_bytes()
        trunc_file = tmp_path / f"trunc_{length}.png"
        trunc_file.write_bytes(raw_data[:length])

        with pytest.raises((Exception,)):
            with Image.open(trunc_file) as img:
                img.load()

    def test_corrupted_idat_zlib_stream(self, tmp_path: Path) -> None:
        """Corrupted IDAT chunk payload must fail without unhandled crash."""
        sample_path = STYLES_ROOT / "sty_01_hoang_mang_co_lo" / "floor.png"
        data = bytearray(sample_path.read_bytes())
        idat_pos = data.find(b"IDAT")
        assert idat_pos != -1
        # Corrupt 16 bytes of zlib compressed payload
        for i in range(16):
            data[idat_pos + 8 + i] = (data[idat_pos + 8 + i] ^ 0xFF)

        corrupt_file = tmp_path / "corrupt_idat.png"
        corrupt_file.write_bytes(data)
        with pytest.raises((Exception,)):
            with Image.open(corrupt_file) as img:
                img.load()

    def test_corrupted_crc32_checksum(self, tmp_path: Path) -> None:
        """Tampered CRC32 checksum in PNG chunk is rejected or flagged."""
        sample_path = STYLES_ROOT / "sty_01_hoang_mang_co_lo" / "floor.png"
        data = bytearray(sample_path.read_bytes())
        ihdr_pos = data.find(b"IHDR")
        assert ihdr_pos != -1
        # Invert IHDR CRC32 (last 4 bytes of chunk)
        data[ihdr_pos + 17] ^= 0xFF
        corrupt_file = tmp_path / "corrupt_crc.png"
        corrupt_file.write_bytes(data)
        with pytest.raises((Exception,)):
            with Image.open(corrupt_file) as img:
                img.verify()

    def test_nonexistent_paths_and_traversal(self) -> None:
        """Invalid paths and path traversal payloads fail safely."""
        traversal_paths = [
            STYLES_ROOT / "sty_non_existent" / "floor.png",
            STYLES_ROOT / ".." / ".." / "assets" / "ghost.png",
            STYLES_ROOT / "sty_01_hoang_mang_co_lo" / "missing.png",
            Path("C:/Windows/System32/drivers/etc/hosts.png"),
        ]
        for p in traversal_paths:
            assert not p.is_file()
            with pytest.raises((FileNotFoundError, OSError)):
                with Image.open(p) as img:
                    img.load()

    def test_disguised_non_png_files(self, tmp_path: Path) -> None:
        """Non-PNG text, HTML, and JSON payloads disguised as .png are rejected."""
        payloads: Dict[str, bytes] = {
            "html.png": b"<html><body>404 Not Found</body></html>",
            "json.png": b'{"error": "Unauthorized", "code": 401}',
            "fake_gif.png": b"GIF89a\x01\x00\x01\x00\x80\x00\x00",
            "binary_junk.png": b"\x00\xFF\xAA\x55" * 128,
        }
        for name, content in payloads.items():
            f = tmp_path / name
            f.write_bytes(content)
            with pytest.raises((Exception,)):
                with Image.open(f) as img:
                    img.load()

    def test_decompression_dimension_overflow_safety(self, tmp_path: Path) -> None:
        """Crafted huge dimension header is trapped safely by PIL safety limits."""
        # Create minimal 1x1 PNG then rewrite IHDR width/height to 65535x65535
        bio = io.BytesIO()
        Image.new("RGB", (1, 1), (0, 0, 0)).save(bio, format="PNG")
        data = bytearray(bio.getvalue())
        ihdr_pos = data.find(b"IHDR")
        struct.pack_into(">II", data, ihdr_pos + 4, 65535, 65535)
        # Recalculate CRC for IHDR
        chunk_data = data[ihdr_pos : ihdr_pos + 17]
        new_crc = zlib.crc32(chunk_data)
        struct.pack_into(">I", data, ihdr_pos + 17, new_crc)

        bomb_path = tmp_path / "bomb.png"
        bomb_path.write_bytes(data)
        # Loading should trigger DecompressionBombError or decompression failure
        with pytest.raises((Exception,)):
            with Image.open(bomb_path) as img:
                img.load()


class TestMemoryConsumptionVerification:
    """Verifies RAM and disk budgets: active biome < 500 KB, all 30 styles < 8 MB."""

    def test_active_biome_uncompressed_footprint_under_500kb(self) -> None:
        """Every active biome raw RGBA buffer must be strictly < 500 KB."""
        styles = list_available_styles()
        max_biome_ram = 0

        for s_id in styles:
            s_dir = STYLES_ROOT / s_id.lower()
            biome_ram = 0
            # 9 core textures
            for fname in CORE_ASSET_NAMES:
                with Image.open(s_dir / fname) as img:
                    biome_ram += img.width * img.height * 4
            # 3 prop individual sprites
            for p_file in (s_dir / "props").glob("*.png"):
                with Image.open(p_file) as img:
                    biome_ram += img.width * img.height * 4

            if biome_ram > max_biome_ram:
                max_biome_ram = biome_ram

            # Strict 500 KB (512,000 bytes) threshold
            assert biome_ram < 500 * 1024, (
                f"{s_id} memory {biome_ram / 1024:.2f} KB exceeds 500 KB limit"
            )

        # Confirm exact expected footprint: 416 KB
        assert max_biome_ram == 416 * 1024, f"Unexpected footprint: {max_biome_ram}"

    def test_all_30_styles_aggregate_disk_size_under_8mb(self) -> None:
        """Total disk size across all 30 style directories must be strictly < 8 MB."""
        total_disk_bytes = 0
        for s_id in list_available_styles():
            s_dir = STYLES_ROOT / s_id.lower()
            for f in s_dir.rglob("*.png"):
                total_disk_bytes += f.stat().st_size

        total_disk_mb = total_disk_bytes / (1024 * 1024)
        # Confirm empirical total with realistic textures is well under 8 MB budget
        assert total_disk_mb < 8.0, f"Total disk size {total_disk_mb:.2f} MB unexpectedly high"

    def test_all_30_styles_albedo_buffer_under_8mb(self) -> None:
        """In-memory core albedo textures across all 30 styles must be < 8 MB."""
        albedo_names = ("floor.png", "wall.png", "path.png", "liquid.png", "props.png")
        total_albedo_ram = 0

        for s_id in list_available_styles():
            s_dir = STYLES_ROOT / s_id.lower()
            for fname in albedo_names:
                with Image.open(s_dir / fname) as img:
                    total_albedo_ram += img.width * img.height * 4

        total_albedo_mb = total_albedo_ram / (1024 * 1024)
        assert total_albedo_mb < 8.0, (
            f"Albedo buffer {total_albedo_mb:.2f} MB exceeds 8.0 MB budget"
        )
        assert abs(total_albedo_mb - 6.09) < 0.1, f"Expected ~6.09 MB, got {total_albedo_mb:.2f} MB"


class TestTexturePixelIntegrityAndNormalVectors:
    """Verifies pixel validity, alpha masks, normal vector distributions, and props."""

    def test_albedo_tiles_not_empty_and_valid_alpha(self) -> None:
        """Floor, wall, path, liquid tiles must contain non-transparent colored pixels."""
        styles = list_available_styles()
        for s_id in styles:
            s_dir = STYLES_ROOT / s_id.lower()
            for fname in ("floor.png", "wall.png", "path.png", "liquid.png"):
                with Image.open(s_dir / fname) as img:
                    assert img.mode == "RGBA"
                    alpha_chan = img.split()[3]
                    extrema = alpha_chan.getextrema()
                    # Must have opaque pixels (max alpha == 255)
                    assert extrema[1] == 255, f"{s_id}/{fname} has no opaque pixels"

    def test_normal_map_z_vector_distribution(self) -> None:
        """Tangent normal maps must have valid Z orientation across all 30 styles."""
        styles = list_available_styles()
        normal_names = (
            "floor_normal.png",
            "wall_normal.png",
            "path_normal.png",
            "liquid_normal.png",
        )
        for s_id in styles:
            s_dir = STYLES_ROOT / s_id.lower()
            for fname in normal_names:
                with Image.open(s_dir / fname) as img:
                    assert img.mode == "RGBA"
                    r, g, b, a = img.split()
                    b_extrema = b.getextrema()
                    # Minimum blue on normal map must not be degenerate zero for non-transparent pixels
                    assert b_extrema[1] >= 128, f"{s_id}/{fname} max blue {b_extrema[1]} < 128"

    def test_props_sheet_segmentation(self) -> None:
        """Props composite sheet (192x64) segments cleanly into 3 non-empty 64x64 cells."""
        for s_id in list_available_styles():
            sheet_path = STYLES_ROOT / s_id.lower() / "props.png"
            with Image.open(sheet_path) as sheet:
                assert sheet.width == 192 and sheet.height == 64
                for i in range(3):
                    cell = sheet.crop((i * 64, 0, (i + 1) * 64, 64))
                    alpha_extrema = cell.split()[3].getextrema()
                    assert alpha_extrema[1] > 0, f"{s_id}/props.png cell {i} is empty"
