# Generated by Copilot
# Maintained by Dinesh Kumar C (https://github.com/Dinesh210805/blender-mcp)
# Forked from original work by Siddharth Ahuja (https://github.com/ahujasid/blender-mcp)

"""
Tests for Hyper3D Rodin integration tools.

This module contains comprehensive tests for Hyper3D model generation,
job status polling, asset importing, and related functionality including
validation, error handling, and security measures.
"""

import pytest
import json
import os
import base64
from unittest.mock import Mock, patch, mock_open, MagicMock
from pathlib import Path
from mcp.server.fastmcp import Context

from blender_mcp.tools.hyper3d_tools import (
    get_hyper3d_status,
    generate_hyper3d_model_via_text,
    generate_hyper3d_model_via_images,
    poll_rodin_job_status,
    import_generated_asset,
    _process_bbox_condition,
    _process_image_paths,
    _process_image_urls,
)
from blender_mcp.exceptions import BlenderMCPError, ConnectionError, ValidationError


class TestHyper3DStatus:
    """Test cases for Hyper3D status checking."""
    
    def test_get_hyper3d_status_success(self, mock_context, mock_blender_connection):
        """Test successful Hyper3D status check."""
        # Arrange
        mock_blender_connection.send_command.return_value = {
            "enabled": True,
            "message": "Hyper3D Rodin integration is enabled. Mode: MAIN_SITE"
        }
        
        # Act
        result = get_hyper3d_status()
        
        # Assert
        assert "Hyper3D Rodin integration is enabled" in result
        mock_blender_connection.send_command.assert_called_once_with("get_hyper3d_status")
    
    def test_get_hyper3d_status_disabled(self, mock_context, mock_blender_connection):
        """Test Hyper3D status check when disabled."""
        # Arrange
        mock_blender_connection.send_command.return_value = {
            "enabled": False,
            "message": "Hyper3D Rodin integration is disabled."
        }
        
        # Act
        result = get_hyper3d_status()
        
        # Assert
        assert result == "Hyper3D Rodin integration is disabled."
    
    def test_get_hyper3d_status_invalid_response(self, mock_context, mock_blender_connection):
        """Test handling of invalid response from Blender."""
        # Arrange
        mock_blender_connection.send_command.return_value = "invalid response"
        
        # Act & Assert
        with pytest.raises(BlenderMCPError, match="Invalid response from Blender"):
            get_hyper3d_status()


class TestTextToModel:
    """Test cases for text-to-3D model generation."""
    
    def test_generate_hyper3d_model_via_text_success(self, mock_context, mock_blender_connection):
        """Test successful text-to-3D generation."""
        # Arrange
        mock_blender_connection.send_command.return_value = {
            "submit_time": "2024-01-01T12:00:00Z",
            "uuid": "task123",
            "jobs": {"subscription_key": "sub456"}
        }
        
        # Act
        result = generate_hyper3d_model_via_text("red sports car")
        
        # Assert
        result_data = json.loads(result)
        assert result_data["task_uuid"] == "task123"
        assert result_data["subscription_key"] == "sub456"
        
        mock_blender_connection.send_command.assert_called_once_with(
            "create_rodin_job",
            {
                "text_prompt": "red sports car",
                "images": None,
                "bbox_condition": None
            }
        )
    
    def test_generate_hyper3d_model_via_text_with_bbox(self, mock_context, mock_blender_connection):
        """Test text-to-3D generation with bbox condition."""
        # Arrange
        mock_blender_connection.send_command.return_value = {
            "submit_time": "2024-01-01T12:00:00Z",
            "uuid": "task123",
            "jobs": {"subscription_key": "sub456"}
        }
        
        # Act
        result = generate_hyper3d_model_via_text("car", bbox_condition=[2.0, 1.0, 0.8])
        
        # Assert
        result_data = json.loads(result)
        assert result_data["task_uuid"] == "task123"
        
        # Check that bbox was processed correctly (normalized to integers)
        expected_bbox = [100, 50, 40]  # Normalized values
        mock_blender_connection.send_command.assert_called_once()
        call_args = mock_blender_connection.send_command.call_args[0][1]
        assert call_args["bbox_condition"] == expected_bbox
    
    def test_generate_hyper3d_model_via_text_validation_errors(self, mock_context):
        """Test input validation errors."""
        # Test empty prompt
        with pytest.raises(ValidationError, match="text_prompt.*cannot be empty"):
            generate_hyper3d_model_via_text("")
        
        # Test prompt too long
        with pytest.raises(ValidationError, match="text_prompt.*too long"):
            generate_hyper3d_model_via_text("a" * 600)
        
        # Test invalid bbox condition
        with pytest.raises(ValidationError, match="bbox_condition must be a list"):
            generate_hyper3d_model_via_text("car", bbox_condition="invalid")
    
    def test_generate_hyper3d_model_via_text_failure(self, mock_context, mock_blender_connection):
        """Test handling of generation failure."""
        # Arrange
        mock_blender_connection.send_command.return_value = {
            "error": "Insufficient credits"
        }
        
        # Act
        result = generate_hyper3d_model_via_text("car")
        
        # Assert
        result_data = json.loads(result)
        assert "error" in result_data


class TestImageToModel:
    """Test cases for image-to-3D model generation."""
    
    @patch('blender_mcp.tools.hyper3d_tools.os.path.exists')
    @patch('blender_mcp.tools.hyper3d_tools.os.path.isfile')
    @patch('blender_mcp.tools.hyper3d_tools.os.path.getsize')
    @patch('builtins.open', new_callable=mock_open, read_data=b'fake_image_data')
    def test_generate_hyper3d_model_via_images_paths_success(
        self, mock_file, mock_getsize, mock_isfile, mock_exists, 
        mock_context, mock_blender_connection
    ):
        """Test successful image-to-3D generation using file paths."""
        # Arrange
        mock_exists.return_value = True
        mock_isfile.return_value = True
        mock_getsize.return_value = 1024 * 1024  # 1MB
        
        mock_blender_connection.send_command.return_value = {
            "submit_time": "2024-01-01T12:00:00Z",
            "uuid": "task123",
            "jobs": {"subscription_key": "sub456"}
        }
        
        # Act
        result = generate_hyper3d_model_via_images(
            input_image_paths=["/path/to/image1.jpg", "/path/to/image2.png"]
        )
        
        # Assert
        result_data = json.loads(result)
        assert result_data["task_uuid"] == "task123"
        assert result_data["subscription_key"] == "sub456"
        
        # Verify the images parameter contains base64 encoded data
        call_args = mock_blender_connection.send_command.call_args[0][1]
        assert call_args["images"] is not None
        assert len(call_args["images"]) == 2
        assert call_args["images"][0][0] == ".jpg"  # File extension
        assert isinstance(call_args["images"][0][1], str)  # Base64 data
    
    def test_generate_hyper3d_model_via_images_urls_success(self, mock_context, mock_blender_connection):
        """Test successful image-to-3D generation using URLs."""
        # Arrange
        mock_blender_connection.send_command.return_value = {
            "submit_time": "2024-01-01T12:00:00Z",
            "uuid": "task123",
            "jobs": {"subscription_key": "sub456"}
        }
        
        # Act
        result = generate_hyper3d_model_via_images(
            input_image_urls=["https://example.com/image1.jpg", "https://example.com/image2.png"]
        )
        
        # Assert
        result_data = json.loads(result)
        assert result_data["task_uuid"] == "task123"
        
        call_args = mock_blender_connection.send_command.call_args[0][1]
        assert call_args["images"] == ["https://example.com/image1.jpg", "https://example.com/image2.png"]
    
    def test_generate_hyper3d_model_via_images_validation_errors(self, mock_context):
        """Test input validation errors."""
        # Test both parameters provided
        with pytest.raises(ValidationError, match="Cannot specify both"):
            generate_hyper3d_model_via_images(
                input_image_paths=["/path/to/image.jpg"],
                input_image_urls=["https://example.com/image.jpg"]
            )
        
        # Test neither parameter provided
        with pytest.raises(ValidationError, match="Must specify either"):
            generate_hyper3d_model_via_images()
    
    @patch('blender_mcp.tools.hyper3d_tools.os.path.exists')
    def test_generate_hyper3d_model_via_images_invalid_paths(self, mock_exists, mock_context):
        """Test handling of invalid file paths."""
        # Arrange
        mock_exists.return_value = False
        
        # Act & Assert
        with pytest.raises(ValidationError, match="Image file does not exist"):
            generate_hyper3d_model_via_images(input_image_paths=["/nonexistent/image.jpg"])


class TestJobStatusPolling:
    """Test cases for Hyper3D job status polling."""
    
    def test_poll_rodin_job_status_main_site_mode(self, mock_context, mock_blender_connection):
        """Test job status polling in MAIN_SITE mode."""
        # Arrange
        mock_blender_connection.send_command.return_value = {
            "status": ["Processing", "Done", "Done"]
        }
        
        # Act
        result = poll_rodin_job_status(subscription_key="sub123")
        
        # Assert
        assert result["status"] == ["Processing", "Done", "Done"]
        mock_blender_connection.send_command.assert_called_once_with(
            "poll_rodin_job_status",
            {"subscription_key": "sub123"}
        )
    
    def test_poll_rodin_job_status_fal_ai_mode(self, mock_context, mock_blender_connection):
        """Test job status polling in FAL_AI mode."""
        # Arrange
        mock_blender_connection.send_command.return_value = {
            "status": "IN_PROGRESS",
            "progress": 0.6
        }
        
        # Act
        result = poll_rodin_job_status(request_id="req456")
        
        # Assert
        assert result["status"] == "IN_PROGRESS"
        assert result["progress"] == 0.6
        mock_blender_connection.send_command.assert_called_once_with(
            "poll_rodin_job_status",
            {"request_id": "req456"}
        )
    
    def test_poll_rodin_job_status_validation_errors(self, mock_context):
        """Test input validation errors."""
        # Test both parameters provided
        with pytest.raises(ValidationError, match="Cannot specify both"):
            poll_rodin_job_status(subscription_key="sub123", request_id="req456")
        
        # Test neither parameter provided
        with pytest.raises(ValidationError, match="Must specify either"):
            poll_rodin_job_status()
    
    def test_poll_rodin_job_status_string_response(self, mock_context, mock_blender_connection):
        """Test handling of string response (legacy format)."""
        # Arrange
        mock_blender_connection.send_command.return_value = "COMPLETED"
        
        # Act
        result = poll_rodin_job_status(subscription_key="sub123")
        
        # Assert
        assert result == {"status": "COMPLETED"}


class TestAssetImporting:
    """Test cases for generated asset importing."""
    
    def test_import_generated_asset_success(self, mock_context, mock_blender_connection):
        """Test successful asset import."""
        # Arrange
        mock_blender_connection.send_command.return_value = {
            "success": True,
            "imported_objects": ["GeneratedModel"],
            "message": "Import successful"
        }
        
        # Act
        result = import_generated_asset("MyModel", task_uuid="task123")
        
        # Assert
        assert result["success"] is True
        assert result["imported_objects"] == ["GeneratedModel"]
        mock_blender_connection.send_command.assert_called_once_with(
            "import_generated_asset",
            {"name": "MyModel", "task_uuid": "task123"}
        )
    
    def test_import_generated_asset_failure(self, mock_context, mock_blender_connection):
        """Test failed asset import."""
        # Arrange
        mock_blender_connection.send_command.return_value = {
            "success": False,
            "message": "Asset not found"
        }
        
        # Act
        result = import_generated_asset("MyModel", request_id="req456")
        
        # Assert
        assert result["success"] is False
        assert result["message"] == "Asset not found"
    
    def test_import_generated_asset_validation_errors(self, mock_context):
        """Test input validation errors."""
        # Test empty name
        with pytest.raises(ValidationError, match="name.*cannot be empty"):
            import_generated_asset("", task_uuid="task123")
        
        # Test both parameters provided
        with pytest.raises(ValidationError, match="Cannot specify both"):
            import_generated_asset("MyModel", task_uuid="task123", request_id="req456")
        
        # Test neither parameter provided
        with pytest.raises(ValidationError, match="Must specify either"):
            import_generated_asset("MyModel")


class TestBboxProcessing:
    """Test cases for bbox condition processing."""
    
    def test_process_bbox_condition_none(self):
        """Test processing None bbox condition."""
        # Act
        result = _process_bbox_condition(None)
        
        # Assert
        assert result is None
    
    def test_process_bbox_condition_valid(self):
        """Test processing valid bbox condition."""
        # Act
        result = _process_bbox_condition([2.0, 1.0, 0.8])
        
        # Assert
        assert result == [100, 50, 40]  # Normalized to integers
    
    def test_process_bbox_condition_validation_errors(self):
        """Test bbox condition validation errors."""
        # Test wrong length
        with pytest.raises(ValidationError, match="exactly 3 numbers"):
            _process_bbox_condition([1.0, 2.0])
        
        # Test non-numeric values
        with pytest.raises(ValidationError, match="must be numeric"):
            _process_bbox_condition([1.0, "invalid", 3.0])
        
        # Test negative values
        with pytest.raises(ValidationError, match="greater than zero"):
            _process_bbox_condition([1.0, -2.0, 3.0])
        
        # Test extremely large values
        with pytest.raises(ValidationError, match="too large"):
            _process_bbox_condition([1000000.0, 1.0, 1.0])


class TestImageProcessing:
    """Test cases for image processing utilities."""
    
    @patch('blender_mcp.tools.hyper3d_tools.os.path.exists')
    @patch('blender_mcp.tools.hyper3d_tools.os.path.isfile')
    @patch('blender_mcp.tools.hyper3d_tools.os.path.getsize')
    @patch('builtins.open', new_callable=mock_open, read_data=b'fake_image_data')
    def test_process_image_paths_success(self, mock_file, mock_getsize, mock_isfile, mock_exists):
        """Test successful image path processing."""
        # Arrange
        mock_exists.return_value = True
        mock_isfile.return_value = True
        mock_getsize.return_value = 1024 * 1024  # 1MB
        
        # Act
        result = _process_image_paths(["/path/to/image1.jpg", "/path/to/image2.png"])
        
        # Assert
        assert len(result) == 2
        assert result[0][0] == ".jpg"  # File extension
        assert isinstance(result[0][1], str)  # Base64 data
        assert result[1][0] == ".png"
    
    def test_process_image_paths_validation_errors(self):
        """Test image path validation errors."""
        # Test empty list
        with pytest.raises(ValidationError, match="non-empty list"):
            _process_image_paths([])
        
        # Test too many images
        with pytest.raises(ValidationError, match="Too many image files"):
            _process_image_paths([f"/path/image{i}.jpg" for i in range(15)])
    
    @patch('blender_mcp.tools.hyper3d_tools.os.path.exists')
    def test_process_image_paths_file_not_found(self, mock_exists):
        """Test handling of non-existent files."""
        # Arrange
        mock_exists.return_value = False
        
        # Act & Assert
        with pytest.raises(ValidationError, match="does not exist"):
            _process_image_paths(["/nonexistent/image.jpg"])
    
    def test_process_image_urls_success(self):
        """Test successful image URL processing."""
        # Arrange
        urls = [
            "https://example.com/image1.jpg",
            "http://test.com/image2.png"
        ]
        
        # Act
        result = _process_image_urls(urls)
        
        # Assert
        assert result == urls
    
    def test_process_image_urls_validation_errors(self):
        """Test image URL validation errors."""
        # Test empty list
        with pytest.raises(ValidationError, match="non-empty list"):
            _process_image_urls([])
        
        # Test invalid URL scheme
        with pytest.raises(ValidationError, match="Invalid URL scheme"):
            _process_image_urls(["ftp://example.com/image.jpg"])
        
        # Test malformed URL
        with pytest.raises(ValidationError, match="Invalid URL"):
            _process_image_urls(["not_a_url"])
        
        # Test URL too long
        with pytest.raises(ValidationError, match="URL too long"):
            _process_image_urls([f"https://example.com/{'a' * 2000}.jpg"])


# Integration test fixtures
@pytest.fixture
def sample_generation_response():
    """Sample Hyper3D generation response for testing."""
    return {
        "submit_time": "2024-01-01T12:00:00Z",
        "uuid": "test_task_123",
        "jobs": {
            "subscription_key": "test_sub_456"
        }
    }

@pytest.fixture
def sample_job_status_response():
    """Sample job status response for testing."""
    return {
        "status": ["Processing", "Done", "Done"],
        "progress": 1.0,
        "estimated_time": "2 minutes"
    }

@pytest.fixture
def sample_import_response():
    """Sample asset import response for testing."""
    return {
        "success": True,
        "imported_objects": ["GeneratedModel_Mesh", "GeneratedModel_Material"],
        "message": "Asset imported successfully",
        "bounding_box": {
            "min": [-1.0, -1.0, 0.0],
            "max": [1.0, 1.0, 2.0]
        }
    }
