"""Tests for godotiq_spatial_audit tool.

Sprint 3c RED phase: These tests define expected behavior for the spatial audit
tool and will fail until the implementation is complete.

Design Doc: Sprint 3c — Spatial Audit
Generated: 2026-03-03 | Budget Used: 11/11 integration, 0/2 E2E
"""

from __future__ import annotations

from godotiq.session import GodotIQSession
from godotiq.tools.spatial import _build_spatial_audit


class TestSpatialAuditAllChecks:
    # AC: "Spatial audit runs all checks by default and returns aggregate results"
    # Behavior: Call audit with no check filter -> returns total_issues count and by_check breakdown
    # @category: core-functionality
    # @dependency: GodotIQSession, scene resolver
    # @complexity: medium
    # ROI: 90 | Business Value: 9 (core tool output) | Frequency: 10 (every audit call)
    def test_audit_all_checks(self, session: GodotIQSession) -> None:
        """All checks run by default; result has total_issues (int) and by_check (dict)."""
        result = _build_spatial_audit(
            session, "res://scenes/equipment/fdm_printer.tscn"
        )
        # SKELETON: result must contain total_issues as an integer
        assert "total_issues" in result
        assert isinstance(result["total_issues"], int)
        # SKELETON: result must contain by_check dict mapping check names to issue counts
        assert "by_check" in result
        assert isinstance(result["by_check"], dict)
        # SKELETON: by_check keys should include known check names
        expected_checks = {
            "floating_objects",
            "scale_mismatch",
            "z_fighting",
            "empty_markers",
            "overlapping",
            "extreme_positions",
        }
        assert expected_checks <= set(result["by_check"].keys())


class TestSpatialAuditFloatingObjects:
    # AC: "Detect nodes with high Y coordinates that are not lights or cameras"
    # Behavior: Scene with node at high Y -> flagged as floating_objects issue
    # @category: core-functionality
    # @dependency: GodotIQSession, scene resolver, world transforms
    # @complexity: medium
    # ROI: 82 | Business Value: 8 | Frequency: 7
    def test_audit_floating_objects(self, session: GodotIQSession) -> None:
        """Detects nodes with high Y that are not lights or cameras."""
        result = _build_spatial_audit(
            session, "res://scenes/equipment/fdm_printer.tscn"
        )
        # SKELETON: issues list should contain entries where check == "floating_objects"
        # for any node with Y position significantly above scene center that is
        # not a light (OmniLight3D, SpotLight3D, DirectionalLight3D) or Camera3D
        assert "issues" in result
        floating = [i for i in result["issues"] if i["check"] == "floating_objects"]
        # SKELETON: verify each floating issue has node_name, position, and severity
        for issue in floating:
            assert "node" in issue
            assert "position" in issue
            assert "severity" in issue


class TestSpatialAuditScaleMismatch:
    # AC: "Detect nodes with extreme scale relative to siblings"
    # Behavior: Node with scale much larger/smaller than siblings -> scale_mismatch
    # @category: core-functionality
    # @dependency: GodotIQSession, world transforms
    # @complexity: medium
    # ROI: 78 | Business Value: 8 | Frequency: 6
    def test_audit_scale_mismatch(self, session: GodotIQSession) -> None:
        """Detects nodes with extreme scale vs siblings."""
        result = _build_spatial_audit(
            session, "res://scenes/equipment/fdm_printer.tscn"
        )
        # Sibling scales: PrinterModel 0.35, FilamentSpool 0.15, NozzleMesh 0.2,
        # WorkPoint 1.0, StatusLight 1.0. Median ~0.35 → WorkPoint/StatusLight are outliers.
        assert "issues" in result
        scale_issues = [i for i in result["issues"] if i["check"] == "scale_mismatch"]
        assert len(scale_issues) >= 1
        scale_nodes = [i["node"] for i in scale_issues]
        # WorkPoint and StatusLight at 1.0 deviate from the median
        assert "WorkPoint" in scale_nodes or "StatusLight" in scale_nodes


class TestSpatialAuditZFighting:
    # AC: "Detect nodes at the same Y/XZ position that may cause z-fighting"
    # Behavior: Two nodes at overlapping positions -> z_fighting issue
    # @category: core-functionality
    # @dependency: GodotIQSession, world transforms
    # @complexity: medium
    # ROI: 72 | Business Value: 7 | Frequency: 5
    def test_audit_z_fighting(self, session: GodotIQSession) -> None:
        """Detects nodes at same Y/XZ position (potential z-fighting)."""
        result = _build_spatial_audit(
            session, "res://scenes/equipment/fdm_printer.tscn"
        )
        # SKELETON: z_fighting check should look for pairs of nodes at nearly
        # identical positions; verify issue structure when present
        assert "issues" in result
        zf_issues = [i for i in result["issues"] if i["check"] == "z_fighting"]
        for issue in zf_issues:
            assert "node" in issue
            assert "severity" in issue


class TestSpatialAuditEmptyMarkers:
    # AC: "Detect Marker3D nodes with groups but no children"
    # Behavior: Marker3D with group assignment but zero children -> empty_markers issue
    # @category: edge-case
    # @dependency: GodotIQSession, scene resolver
    # @complexity: low
    # ROI: 60 | Business Value: 5 | Frequency: 4
    def test_audit_empty_markers(self, session: GodotIQSession) -> None:
        """Detects Marker3D with groups but no children."""
        result = _build_spatial_audit(
            session, "res://scenes/equipment/fdm_printer.tscn"
        )
        # SKELETON: any Marker3D node that has groups but no children should
        # be flagged; verify the check runs even if no markers in fixture
        assert "issues" in result
        marker_issues = [i for i in result["issues"] if i["check"] == "empty_markers"]
        for issue in marker_issues:
            assert "node" in issue
            assert "severity" in issue


class TestSpatialAuditOverlapping:
    # AC: "Detect instance roots placed at the same position"
    # Behavior: Two instance roots at identical positions -> overlapping issue
    # @category: core-functionality
    # @dependency: GodotIQSession, scene resolver, world transforms
    # @complexity: medium
    # ROI: 70 | Business Value: 7 | Frequency: 5
    def test_audit_overlapping(self, session: GodotIQSession) -> None:
        """Detects instance roots at same position."""
        result = _build_spatial_audit(
            session, "res://scenes/equipment/fdm_printer.tscn"
        )
        # SKELETON: overlapping check should examine instance root nodes;
        # verify issue structure when present
        assert "issues" in result
        overlap = [i for i in result["issues"] if i["check"] == "overlapping"]
        for issue in overlap:
            assert "node" in issue
            assert "severity" in issue


class TestSpatialAuditExtremePositions:
    # AC: "Detect nodes positioned far from the scene center"
    # Behavior: Node at extreme distance from scene centroid -> extreme_positions issue
    # @category: edge-case
    # @dependency: GodotIQSession, world transforms
    # @complexity: low
    # ROI: 65 | Business Value: 6 | Frequency: 4
    def test_audit_extreme_positions(self, session: GodotIQSession) -> None:
        """Detects nodes far from scene center."""
        result = _build_spatial_audit(
            session, "res://scenes/equipment/fdm_printer.tscn"
        )
        # SKELETON: extreme_positions check uses distance from scene centroid;
        # verify issue structure when present
        assert "issues" in result
        extreme = [i for i in result["issues"] if i["check"] == "extreme_positions"]
        for issue in extreme:
            assert "node" in issue
            assert "position" in issue
            assert "severity" in issue


class TestSpatialAuditSeverityOrder:
    # AC: "Issues are sorted by severity: warnings before info"
    # Behavior: Result issues list is ordered warning-first, then info
    # @category: integration
    # @dependency: GodotIQSession
    # @complexity: low
    # ROI: 68 | Business Value: 6 | Frequency: 8
    def test_audit_severity_order(self, session: GodotIQSession) -> None:
        """Warnings appear before info in the issues list."""
        result = _build_spatial_audit(
            session, "res://scenes/equipment/fdm_printer.tscn"
        )
        assert "issues" in result
        severity_order = {"warning": 0, "info": 1}
        issues = result["issues"]
        for i in range(len(issues) - 1):
            current = severity_order.get(issues[i]["severity"], 99)
            next_val = severity_order.get(issues[i + 1]["severity"], 99)
            assert current <= next_val


class TestSpatialAuditCap:
    # AC: "Maximum 50 issues returned to prevent token bloat"
    # Behavior: Even with many issues, result is capped at 50
    # @category: edge-case
    # @dependency: GodotIQSession
    # @complexity: low
    # ROI: 55 | Business Value: 5 | Frequency: 3
    def test_audit_cap(self, session: GodotIQSession) -> None:
        """Max 50 issues returned."""
        result = _build_spatial_audit(
            session, "res://scenes/equipment/fdm_printer.tscn"
        )
        # SKELETON: regardless of how many issues exist, the issues list
        # must be capped at 50 entries
        assert "issues" in result
        assert len(result["issues"]) <= 50


class TestSpatialAuditSpecificChecks:
    # AC: "Passing checks parameter runs only the specified checks"
    # Behavior: checks=["floating_objects"] -> only floating_objects issues returned
    # @category: core-functionality
    # @dependency: GodotIQSession
    # @complexity: low
    # ROI: 75 | Business Value: 7 | Frequency: 6
    def test_audit_specific_checks(self, session: GodotIQSession) -> None:
        """Passing checks=['floating_objects'] only runs that check."""
        result = _build_spatial_audit(
            session,
            "res://scenes/equipment/fdm_printer.tscn",
            checks=["floating_objects"],
        )
        # SKELETON: only floating_objects issues should appear
        assert "issues" in result
        for issue in result["issues"]:
            assert issue["check"] == "floating_objects"
        # SKELETON: by_check should only contain the requested check
        assert "by_check" in result
        assert set(result["by_check"].keys()) == {"floating_objects"}


class TestSpatialAuditZFightingMeshOnly:
    # AC: "z_fighting only considers mesh geometry nodes, not organizational containers"
    # Behavior: Node3D/Marker3D at (0,0,0) are containers, not z-fighting candidates
    # @category: bugfix
    # @dependency: GodotIQSession, scene resolver
    # @complexity: medium
    # ROI: 85 | Business Value: 9 | Frequency: 8
    def test_z_fighting_excludes_non_mesh(self) -> None:
        """z_fighting check excludes pure Node3D/Marker3D containers at origin."""
        from godotiq.parsers.tscn_parser import TscnNode
        from godotiq.parsers.scene_resolver import WorldNode
        from godotiq.tools.spatial import _check_z_fighting

        # Two Node3D containers at the same position (0,0,0) — organizational, not mesh
        container_a = WorldNode(
            node=TscnNode(name="ContainerA", type="Node3D"),
            world_position=(0.0, 0.0, 0.0),
            world_rotation=(0.0, 0.0, 0.0),
            world_scale=(1.0, 1.0, 1.0),
            full_path="ContainerA",
            depth=1,
            is_instance_root=False,
            instance_source=None,
        )
        container_b = WorldNode(
            node=TscnNode(name="ContainerB", type="Marker3D"),
            world_position=(0.0, 0.0, 0.0),
            world_rotation=(0.0, 0.0, 0.0),
            world_scale=(1.0, 1.0, 1.0),
            full_path="ContainerB",
            depth=1,
            is_instance_root=False,
            instance_source=None,
        )
        # A mesh node at the same position
        mesh_a = WorldNode(
            node=TscnNode(name="MeshA", type="MeshInstance3D"),
            world_position=(0.0, 0.0, 0.0),
            world_rotation=(0.0, 0.0, 0.0),
            world_scale=(1.0, 1.0, 1.0),
            full_path="MeshA",
            depth=1,
            is_instance_root=False,
            instance_source=None,
        )
        # A .glb instance at the same position
        mesh_b = WorldNode(
            node=TscnNode(name="ModelB", type=""),
            world_position=(0.0, 0.0, 0.0),
            world_rotation=(0.0, 0.0, 0.0),
            world_scale=(1.0, 1.0, 1.0),
            full_path="ModelB",
            depth=1,
            is_instance_root=True,
            instance_source="res://assets/models/thing.glb",
        )

        nodes = [container_a, container_b, mesh_a, mesh_b]
        issues = _check_z_fighting(nodes)

        # Node3D+Marker3D pair at same position: should NOT be flagged (containers)
        flagged_pairs = {(i["node"], i["other_node"]) for i in issues}
        assert ("ContainerA", "ContainerB") not in flagged_pairs
        assert ("ContainerB", "ContainerA") not in flagged_pairs

        # MeshInstance3D + .glb model at same position: SHOULD be flagged
        assert ("MeshA", "ModelB") in flagged_pairs or ("ModelB", "MeshA") in flagged_pairs


class TestSpatialAuditMissingScene:
    # AC: "Non-existent scene returns error dict"
    # Behavior: Audit on missing path -> {"error": ...}
    # @category: edge-case
    # @dependency: GodotIQSession
    # @complexity: low
    # ROI: 70 | Business Value: 6 | Frequency: 3
    def test_audit_missing_scene(self, session: GodotIQSession) -> None:
        """Returns error dict for non-existent scene."""
        result = _build_spatial_audit(session, "res://nonexistent.tscn")
        assert "error" in result


class TestSpatialAuditSiblingZFightingDowngraded:
    def test_sibling_z_fighting_downgraded(self) -> None:
        """Two mesh nodes under the same parent at identical positions → severity 'info'."""
        from godotiq.parsers.tscn_parser import TscnNode
        from godotiq.parsers.scene_resolver import WorldNode
        from godotiq.tools.spatial import _check_z_fighting

        node_a = WorldNode(
            node=TscnNode(name="TileA", type="MeshInstance3D"),
            world_position=(1.0, 0.0, 1.0),
            world_rotation=(0.0, 0.0, 0.0),
            world_scale=(1.0, 1.0, 1.0),
            full_path="Room/Floor/TileA",
            depth=3,
            is_instance_root=False,
            instance_source=None,
        )
        node_b = WorldNode(
            node=TscnNode(name="TileB", type="MeshInstance3D"),
            world_position=(1.0, 0.0, 1.0),
            world_rotation=(0.0, 0.0, 0.0),
            world_scale=(1.0, 1.0, 1.0),
            full_path="Room/Floor/TileB",
            depth=3,
            is_instance_root=False,
            instance_source=None,
        )
        issues = _check_z_fighting([node_a, node_b])
        assert len(issues) == 1
        assert issues[0]["severity"] == "info"
        assert issues[0]["check"] == "z_fighting"

    def test_root_level_siblings_downgraded(self) -> None:
        """Root-level siblings (no '/' in path) also downgraded to info."""
        from godotiq.parsers.tscn_parser import TscnNode
        from godotiq.parsers.scene_resolver import WorldNode
        from godotiq.tools.spatial import _check_z_fighting

        node_a = WorldNode(
            node=TscnNode(name="TileA", type="MeshInstance3D"),
            world_position=(0.0, 0.0, 0.0),
            world_rotation=(0.0, 0.0, 0.0),
            world_scale=(1.0, 1.0, 1.0),
            full_path="TileA",
            depth=1,
            is_instance_root=False,
            instance_source=None,
        )
        node_b = WorldNode(
            node=TscnNode(name="TileB", type="MeshInstance3D"),
            world_position=(0.0, 0.0, 0.0),
            world_rotation=(0.0, 0.0, 0.0),
            world_scale=(1.0, 1.0, 1.0),
            full_path="TileB",
            depth=1,
            is_instance_root=False,
            instance_source=None,
        )
        issues = _check_z_fighting([node_a, node_b])
        assert len(issues) == 1
        assert issues[0]["severity"] == "info"


class TestSpatialAuditNonSiblingZFightingAlsoInfo:
    def test_non_sibling_z_fighting_also_info(self) -> None:
        """Two mesh nodes under different parents at identical positions → severity 'info'.

        ALL z-fighting is cosmetic (flickering surfaces), never a gameplay bug,
        regardless of parent relationship.
        """
        from godotiq.parsers.tscn_parser import TscnNode
        from godotiq.parsers.scene_resolver import WorldNode
        from godotiq.tools.spatial import _check_z_fighting

        node_a = WorldNode(
            node=TscnNode(name="TileA", type="MeshInstance3D"),
            world_position=(1.0, 0.0, 1.0),
            world_rotation=(0.0, 0.0, 0.0),
            world_scale=(1.0, 1.0, 1.0),
            full_path="Room/Floor/TileA",
            depth=3,
            is_instance_root=False,
            instance_source=None,
        )
        node_b = WorldNode(
            node=TscnNode(name="RugB", type="MeshInstance3D"),
            world_position=(1.0, 0.0, 1.0),
            world_rotation=(0.0, 0.0, 0.0),
            world_scale=(1.0, 1.0, 1.0),
            full_path="Room/Rugs/RugB",
            depth=3,
            is_instance_root=False,
            instance_source=None,
        )
        issues = _check_z_fighting([node_a, node_b])
        assert len(issues) == 1
        assert issues[0]["severity"] == "info"
        assert issues[0]["check"] == "z_fighting"


class TestSpatialAuditMinSeverityFilter:
    def test_min_severity_filter(self, session: GodotIQSession) -> None:
        """min_severity='warning' filters out info issues but shows them in counts."""
        result = _build_spatial_audit(
            session, "res://scenes/equipment/fdm_printer.tscn", min_severity="warning"
        )
        # No info issues in the issues list
        for issue in result["issues"]:
            assert issue["severity"] != "info"
        # by_severity dict present
        assert isinstance(result["by_severity"], dict)
        # shown_issues <= total_issues
        assert result["shown_issues"] <= result["total_issues"]

    def test_min_severity_info_shows_all(self, session: GodotIQSession) -> None:
        """min_severity='info' shows everything."""
        result = _build_spatial_audit(
            session, "res://scenes/equipment/fdm_printer.tscn",
            min_severity="info", detail="full",
        )
        assert result["shown_issues"] == result["total_issues"]
        # If info issues exist in counts, they must appear in the issues list
        info_count = result["by_severity"].get("info", 0)
        if info_count > 0:
            info_in_list = [i for i in result["issues"] if i["severity"] == "info"]
            assert len(info_in_list) == info_count


class TestSpatialAuditDetailLevel:
    def test_detail_default_is_normal(self, session: GodotIQSession) -> None:
        """Default detail level is 'normal'."""
        result = _build_spatial_audit(
            session, "res://scenes/equipment/fdm_printer.tscn", min_severity="info"
        )
        assert result["detail"] == "normal"

    def test_detail_brief_only_critical_and_warning(self, session: GodotIQSession) -> None:
        """detail='brief' shows only critical and warning issues."""
        result = _build_spatial_audit(
            session, "res://scenes/equipment/fdm_printer.tscn",
            min_severity="info", detail="brief",
        )
        for issue in result["issues"]:
            assert issue["severity"] in ("critical", "warning")

    def test_detail_normal_excludes_cosmetic(self, session: GodotIQSession) -> None:
        """detail='normal' excludes z_fighting and overlapping info issues."""
        result = _build_spatial_audit(
            session, "res://scenes/equipment/fdm_printer.tscn",
            min_severity="info", detail="normal",
        )
        for issue in result["issues"]:
            if issue["severity"] == "info":
                assert issue.get("check") not in ("z_fighting", "overlapping")

    def test_detail_full_shows_everything(self, session: GodotIQSession) -> None:
        """detail='full' shows all issues including cosmetic."""
        result = _build_spatial_audit(
            session, "res://scenes/equipment/fdm_printer.tscn",
            min_severity="info", detail="full",
        )
        assert result["detail"] == "full"
        # full should show as many or more issues than normal
        normal = _build_spatial_audit(
            session, "res://scenes/equipment/fdm_printer.tscn",
            min_severity="info", detail="normal",
        )
        assert result["shown_issues"] >= normal["shown_issues"]

    def test_detail_filtered_out_counts(self, session: GodotIQSession) -> None:
        """Non-full detail levels include filtered_out counts."""
        result = _build_spatial_audit(
            session, "res://scenes/equipment/fdm_printer.tscn",
            min_severity="info", detail="normal",
        )
        assert "filtered_out" in result
        assert "total_issues_unfiltered" in result
        assert result["total_issues_unfiltered"] >= result["shown_issues"]

    def test_detail_full_no_filtered_out(self, session: GodotIQSession) -> None:
        """detail='full' does not include filtered_out key."""
        result = _build_spatial_audit(
            session, "res://scenes/equipment/fdm_printer.tscn",
            min_severity="info", detail="full",
        )
        assert "filtered_out" not in result

    def test_detail_invalid_defaults_to_normal(self, session: GodotIQSession) -> None:
        """Invalid detail value defaults to 'normal'."""
        result = _build_spatial_audit(
            session, "res://scenes/equipment/fdm_printer.tscn",
            min_severity="info", detail="bogus",
        )
        assert result["detail"] == "normal"
