#!/usr/bin/env python3
"""
Tests for Matrix 3D Glitch Detector (timmy-config#491).

Covers: glitch_patterns, matrix_glitch_detector core logic.
"""

import json
import sys
import tempfile
import unittest
from pathlib import Path

# Ensure bin/ is importable
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "bin"))

from glitch_patterns import (
    GlitchCategory,
    GlitchPattern,
    GlitchSeverity,
    MATRIX_GLITCH_PATTERNS,
    build_vision_prompt,
    get_pattern_by_category,
    get_patterns_by_severity,
)

# THREEJS_CATEGORIES derived from GlitchCategory enum
THREEJS_CATEGORIES = {cat.value for cat in GlitchCategory}

from matrix_glitch_detector import (
    DetectedGlitch,
    ScanResult,
    _infer_severity,
    _parse_vision_response,
    build_report,
    generate_scan_angles,
    run_demo,
)


class TestGlitchPatterns(unittest.TestCase):
    """Tests for glitch_patterns module."""

    def test_pattern_count(self):
        """Verify we have a reasonable number of defined patterns."""
        self.assertGreaterEqual(len(MATRIX_GLITCH_PATTERNS), 14)  # 10 generic + 6 Three.js

    def test_all_patterns_have_required_fields(self):
        """Every pattern must have category, name, description, severity, prompts."""
        for p in MATRIX_GLITCH_PATTERNS:
            self.assertIsInstance(p.category, GlitchCategory)
            self.assertTrue(p.name)
            self.assertTrue(p.description)
            self.assertIsInstance(p.severity, GlitchSeverity)
            self.assertGreater(len(p.detection_prompts), 0)
            self.assertGreater(len(p.visual_indicators), 0)
            self.assertGreater(p.confidence_threshold, 0)
            self.assertLessEqual(p.confidence_threshold, 1.0)

    def test_pattern_to_dict(self):
        """Pattern serialization should produce a dict with expected keys."""
        p = MATRIX_GLITCH_PATTERNS[0]
        d = p.to_dict()
        self.assertIn("category", d)
        self.assertIn("name", d)
        self.assertIn("severity", d)
        self.assertEqual(d["category"], p.category.value)

    def test_get_patterns_by_severity(self):
        """Severity filter should return only patterns at or above threshold."""
        high_patterns = get_patterns_by_severity(GlitchSeverity.HIGH)
        self.assertTrue(all(p.severity.value in ("high", "critical") for p in high_patterns))
        self.assertGreater(len(high_patterns), 0)

        all_patterns = get_patterns_by_severity(GlitchSeverity.INFO)
        self.assertEqual(len(all_patterns), len(MATRIX_GLITCH_PATTERNS))

    def test_get_pattern_by_category(self):
        """Lookup by category should return the correct pattern."""
        p = get_pattern_by_category(GlitchCategory.FLOATING_ASSETS)
        self.assertIsNotNone(p)
        self.assertEqual(p.category, GlitchCategory.FLOATING_ASSETS)

        missing = get_pattern_by_category("nonexistent_category_value")
        self.assertIsNone(missing)

    def test_build_vision_prompt(self):
        """Vision prompt should contain pattern names and be non-trivial."""
        prompt = build_vision_prompt()
        self.assertGreater(len(prompt), 200)
        self.assertIn("Floating Object", prompt)
        self.assertIn("Z-Fighting", prompt)
        self.assertIn("Missing", prompt)
        # Three.js patterns should be included
        self.assertIn("Shader Compilation Failure", prompt)
        self.assertIn("Bloom Overflow", prompt)

    def test_build_vision_prompt_subset(self):
        """Vision prompt with subset should only include specified patterns."""
        subset = MATRIX_GLITCH_PATTERNS[:3]
        prompt = build_vision_prompt(subset)
        self.assertIn(subset[0].name, prompt)
        self.assertNotIn(MATRIX_GLITCH_PATTERNS[-1].name, prompt)


class TestGlitchDetector(unittest.TestCase):
    """Tests for matrix_glitch_detector module."""

    def test_generate_scan_angles_default(self):
        """Default 4 angles should return front, right, back, left."""
        angles = generate_scan_angles(4)
        self.assertEqual(len(angles), 4)
        labels = [a["label"] for a in angles]
        self.assertIn("front", labels)
        self.assertIn("right", labels)
        self.assertIn("back", labels)
        self.assertIn("left", labels)

    def test_generate_scan_angles_many(self):
        """Requesting more angles than base should still return correct count."""
        angles = generate_scan_angles(12)
        self.assertEqual(len(angles), 12)
        # Should still have the standard ones
        labels = [a["label"] for a in angles]
        self.assertIn("front", labels)

    def test_generate_scan_angles_few(self):
        """Requesting fewer angles should return fewer."""
        angles = generate_scan_angles(2)
        self.assertEqual(len(angles), 2)

    def test_detected_glitch_dataclass(self):
        """DetectedGlitch should serialize cleanly."""
        g = DetectedGlitch(
            id="test001",
            category="floating_assets",
            name="Test Glitch",
            description="A test glitch",
            severity="high",
            confidence=0.85,
            location_x=50.0,
            location_y=30.0,
            screenshot_index=0,
            screenshot_angle="front",
        )
        self.assertEqual(g.id, "test001")
        self.assertTrue(g.timestamp)  # Auto-generated

    def test_infer_severity_critical(self):
        """Missing textures should infer critical/high severity."""
        sev = _infer_severity("missing_textures", 0.9)
        self.assertEqual(sev, "critical")
        sev_low = _infer_severity("missing_textures", 0.5)
        self.assertEqual(sev_low, "high")

    def test_infer_severity_floating(self):
        """Floating assets should infer high/medium severity."""
        sev = _infer_severity("floating_assets", 0.8)
        self.assertEqual(sev, "high")
        sev_low = _infer_severity("floating_assets", 0.5)
        self.assertEqual(sev_low, "medium")

    def test_infer_severity_default(self):
        """Unknown categories should default to medium/low."""
        sev = _infer_severity("unknown_thing", 0.7)
        self.assertEqual(sev, "medium")
        sev_low = _infer_severity("unknown_thing", 0.3)
        self.assertEqual(sev_low, "low")

    def test_parse_vision_response_json_array(self):
        """Should parse a JSON array response."""
        response = json.dumps([
            {
                "category": "floating_assets",
                "name": "Float Test",
                "description": "Chair floating",
                "confidence": 0.9,
                "severity": "high",
                "location_x": 40,
                "location_y": 60,
            }
        ])
        glitches = _parse_vision_response(response, 0, "front")
        self.assertEqual(len(glitches), 1)
        self.assertEqual(glitches[0].category, "floating_assets")
        self.assertAlmostEqual(glitches[0].confidence, 0.9)

    def test_parse_vision_response_wrapped(self):
        """Should parse a response with 'glitches' wrapper key."""
        response = json.dumps({
            "glitches": [
                {
                    "category": "z_fighting",
                    "name": "Shimmer",
                    "confidence": 0.6,
                }
            ]
        })
        glitches = _parse_vision_response(response, 1, "right")
        self.assertEqual(len(glitches), 1)
        self.assertEqual(glitches[0].category, "z_fighting")

    def test_parse_vision_response_empty(self):
        """Should return empty list for non-JSON text."""
        glitches = _parse_vision_response("No glitches found.", 0, "front")
        self.assertEqual(len(glitches), 0)

    def test_parse_vision_response_code_block(self):
        """Should extract JSON from markdown code blocks."""
        response = '```json\n[{"category": "clipping", "name": "Clip", "confidence": 0.7}]\n```'
        glitches = _parse_vision_response(response, 0, "front")
        self.assertEqual(len(glitches), 1)

    def test_build_report(self):
        """Report should have correct summary statistics."""
        angles = generate_scan_angles(4)
        screenshots = [Path(f"/tmp/ss_{i}.png") for i in range(4)]
        glitches = [
            DetectedGlitch(
                id="a", category="floating_assets", name="Float",
                description="", severity="high", confidence=0.8,
                screenshot_index=0, screenshot_angle="front",
            ),
            DetectedGlitch(
                id="b", category="missing_textures", name="Missing",
                description="", severity="critical", confidence=0.95,
                screenshot_index=1, screenshot_angle="right",
            ),
        ]
        report = build_report("https://test.com", angles, screenshots, glitches)

        self.assertEqual(report.total_screenshots, 4)
        self.assertEqual(len(report.glitches), 2)
        self.assertEqual(report.summary["total_glitches"], 2)
        self.assertEqual(report.summary["by_severity"]["critical"], 1)
        self.assertEqual(report.summary["by_severity"]["high"], 1)
        self.assertEqual(report.summary["by_category"]["floating_assets"], 1)
        self.assertEqual(report.metadata["reference"], "timmy-config#491")

    def test_build_report_json_roundtrip(self):
        """Report JSON should parse back correctly."""
        angles = generate_scan_angles(2)
        screenshots = [Path(f"/tmp/ss_{i}.png") for i in range(2)]
        report = build_report("https://test.com", angles, screenshots, [])
        json_str = report.to_json()
        parsed = json.loads(json_str)
        self.assertEqual(parsed["url"], "https://test.com")
        self.assertEqual(parsed["total_screenshots"], 2)

    def test_run_demo(self):
        """Demo mode should produce a report with simulated glitches."""
        with tempfile.NamedTemporaryFile(suffix=".json", delete=False) as f:
            output_path = Path(f.name)

        try:
            report = run_demo(output_path)
            self.assertEqual(len(report.glitches), 6)  # 4 original + 2 Three.js
            self.assertGreater(report.summary["total_glitches"], 0)
            self.assertTrue(output_path.exists())

            # Verify the saved JSON is valid
            saved = json.loads(output_path.read_text())
            self.assertIn("scan_id", saved)
            self.assertIn("glitches", saved)
        finally:
            output_path.unlink(missing_ok=True)


class TestThreeJsPatterns(unittest.TestCase):
    """Tests for Three.js-specific glitch patterns (timmy-config#543)."""

    def test_get_threejs_patterns_returns_only_threejs(self):
        """get_threejs_patterns() should return only Three.js categories."""
        patterns = get_threejs_patterns()
        self.assertEqual(len(patterns), 6)
        for p in patterns:
            self.assertIn(p.category, THREEJS_CATEGORIES)

    def test_threejs_patterns_have_required_fields(self):
        """All Three.js patterns must have valid fields."""
        for p in get_threejs_patterns():
            self.assertIsInstance(p.category, GlitchCategory)
            self.assertTrue(p.name)
            self.assertTrue(p.description)
            self.assertIsInstance(p.severity, GlitchSeverity)
            self.assertGreater(len(p.detection_prompts), 0)
            self.assertGreater(len(p.visual_indicators), 0)

    def test_shader_failure_is_critical(self):
        """Shader compilation failure should be CRITICAL severity."""
        p = get_pattern_by_category(GlitchCategory.SHADER_FAILURE)
        self.assertIsNotNone(p)
        self.assertEqual(p.severity, GlitchSeverity.CRITICAL)

    def test_texture_placeholder_is_critical(self):
        """Texture placeholder (1x1 white) should be CRITICAL severity."""
        p = get_pattern_by_category(GlitchCategory.TEXTURE_PLACEHOLDER)
        self.assertIsNotNone(p)
        self.assertEqual(p.severity, GlitchSeverity.CRITICAL)

    def test_infer_severity_shader_failure(self):
        """Shader failure should infer critical/high."""
        self.assertEqual(_infer_severity("shader_failure", 0.8), "critical")
        self.assertEqual(_infer_severity("shader_failure", 0.5), "high")

    def test_infer_severity_texture_placeholder(self):
        """Texture placeholder should infer critical/high."""
        self.assertEqual(_infer_severity("texture_placeholder", 0.8), "critical")
        self.assertEqual(_infer_severity("texture_placeholder", 0.5), "high")

    def test_infer_severity_uv_mapping(self):
        """UV mapping error should infer high/medium."""
        self.assertEqual(_infer_severity("uv_mapping_error", 0.8), "high")
        self.assertEqual(_infer_severity("uv_mapping_error", 0.5), "medium")

    def test_infer_severity_frustum_culling(self):
        """Frustum culling should infer medium/low."""
        self.assertEqual(_infer_severity("frustum_culling", 0.7), "medium")
        self.assertEqual(_infer_severity("frustum_culling", 0.4), "low")

    def test_infer_severity_shadow_map(self):
        """Shadow map artifact should infer medium/low."""
        self.assertEqual(_infer_severity("shadow_map_artifact", 0.7), "medium")
        self.assertEqual(_infer_severity("shadow_map_artifact", 0.4), "low")

    def test_infer_severity_bloom_overflow(self):
        """Bloom overflow should infer medium/low (default path)."""
        self.assertEqual(_infer_severity("bloom_overflow", 0.7), "medium")
        self.assertEqual(_infer_severity("bloom_overflow", 0.4), "low")

    def test_threejs_patterns_in_vision_prompt(self):
        """Three.js patterns should appear in the composite vision prompt."""
        prompt = build_vision_prompt()
        self.assertIn("shader_failure", prompt)
        self.assertIn("texture_placeholder", prompt)
        self.assertIn("uv_mapping_error", prompt)
        self.assertIn("frustum_culling", prompt)
        self.assertIn("shadow_map_artifact", prompt)
        self.assertIn("bloom_overflow", prompt)

    def test_threejs_subset_prompt(self):
        """Building prompt from Three.js-only patterns should work."""
        threejs = get_threejs_patterns()
        prompt = build_vision_prompt(threejs)
        self.assertIn("Shader Compilation Failure", prompt)
        self.assertNotIn("Floating Object", prompt)  # generic, not Three.js

    def test_report_metadata_version(self):
        """Report metadata should reference both issues."""
        report = run_demo()
        self.assertEqual(report.metadata["detector_version"], "0.2.0")
        self.assertIn("543", report.metadata["reference"])



class TestIntegration(unittest.TestCase):
    """Integration-level tests."""

    def test_full_pipeline_demo(self):
        """End-to-end demo pipeline should complete without errors."""
        report = run_demo()
        self.assertIsNotNone(report.scan_id)
        self.assertTrue(report.timestamp)
        self.assertGreater(report.total_screenshots, 0)

    def test_patterns_cover_matrix_themes(self):
        """Patterns should cover the main Matrix glitch themes."""
        category_values = {p.category.value for p in MATRIX_GLITCH_PATTERNS}
        expected = {"floating_assets", "z_fighting", "missing_textures", "clipping", "broken_normals"}
        self.assertTrue(expected.issubset(category_values))

    def test_patterns_cover_threejs_themes(self):
        """Patterns should cover Three.js-specific glitch themes (#543)."""
        category_values = {p.category.value for p in MATRIX_GLITCH_PATTERNS}
        threejs_expected = {"shader_failure", "texture_placeholder", "uv_mapping_error",
                           "frustum_culling", "shadow_map_artifact", "bloom_overflow"}
        self.assertTrue(threejs_expected.issubset(category_values))


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