import pytest
from unittest.mock import patch


@pytest.fixture(autouse=True)
def mock_registry_client():
    with patch("mca_sdk.registry.client.RegistryClient._fetch_model_config_internal") as mock_fetch:
        # Prevent TypeError: '>=' not supported between instances of 'MagicMock' and 'int'
        from mca_sdk.registry.models import ModelConfig

        mock_fetch.return_value = ModelConfig(
            model_id="test",
            model_version="1.0",
            service_name="test-service",
            team_name="test-team",
            model_category="internal",
            model_type="regression",
        )
        yield mock_fetch


"""Unit tests for Dead Letter Queue encryption at rest.

Tests encryption/decryption functionality, key management, and HIPAA compliance
for DLQ persistence.
"""

import json
import os
import tempfile
import unittest
from pathlib import Path

from mca_sdk.resilience.dead_letter_queue import DeadLetterQueue
from mca_sdk.security.encryption import EncryptionManager


class TestDLQEncryption(unittest.TestCase):
    """Test encryption at rest for Dead Letter Queue."""

    def setUp(self):
        """Set up test fixtures."""
        self.temp_dir = tempfile.mkdtemp()
        self.dlq_path = os.path.join(self.temp_dir, "test_dlq.json")
        self.encryption_key = EncryptionManager.generate_key()

    def tearDown(self):
        """Clean up test files."""
        if os.path.exists(self.dlq_path):
            os.remove(self.dlq_path)
        try:
            os.rmdir(self.temp_dir)
        except OSError:
            pass

    def test_dlq_with_encryption_key_parameter(self):
        """Test DLQ initialization with explicit encryption key."""
        dlq = DeadLetterQueue(
            max_size=10, persist_path=self.dlq_path, encryption_key=self.encryption_key
        )

        # Verify encryption manager is initialized
        self.assertIsNotNone(dlq._encryption_manager)

        # Enqueue item to trigger persistence
        dlq.enqueue(item={"data": "test"}, error=Exception("test"), retry_count=3)

        # File should exist and be encrypted (binary, not readable JSON)
        self.assertTrue(os.path.exists(self.dlq_path))

        with open(self.dlq_path, "rb") as f:
            file_content = f.read()

        # Should be binary encrypted data, not valid JSON
        self.assertIsInstance(file_content, bytes)
        with self.assertRaises((json.JSONDecodeError, UnicodeDecodeError)):
            json.loads(file_content.decode("utf-8"))

    def test_dlq_with_encryption_from_env(self):
        """Test DLQ loads encryption key from environment variable."""
        with patch.dict(
            os.environ, {"MCA_SDK_ENCRYPTION_KEY": self.encryption_key.decode("ascii")}
        ):
            dlq = DeadLetterQueue(max_size=10, persist_path=self.dlq_path)

            # Should have encryption enabled from env
            self.assertIsNotNone(dlq._encryption_manager)

    def test_dlq_without_encryption_warns(self):
        """Test DLQ without encryption logs warning."""
        with patch.dict(os.environ, {}, clear=True):
            with self.assertLogs("mca_sdk.resilience.dead_letter_queue", level="WARNING") as log:
                dlq = DeadLetterQueue(max_size=10, persist_path=self.dlq_path)

                # Should not have encryption
                self.assertIsNone(dlq._encryption_manager)

                # Should log HIPAA compliance warning
                warning_found = any("HIPAA COMPLIANCE WARNING" in record for record in log.output)
                self.assertTrue(warning_found, "Expected HIPAA warning not found in logs")

    def test_encrypted_dlq_persists_and_recovers(self):
        """Test encrypted DLQ data persists and can be recovered."""
        # Create DLQ with encryption and add items
        dlq1 = DeadLetterQueue(
            max_size=10, persist_path=self.dlq_path, encryption_key=self.encryption_key
        )

        test_items = [
            {"data": "item1", "sensitive": "phi_data_1"},
            {"data": "item2", "sensitive": "phi_data_2"},
        ]

        for idx, item in enumerate(test_items):
            dlq1.enqueue(item=item, error=Exception(f"error{idx}"), retry_count=3)

        # Verify file is encrypted
        with open(self.dlq_path, "rb") as f:
            encrypted_content = f.read()

        # Cannot read as JSON (encrypted)
        with self.assertRaises((json.JSONDecodeError, UnicodeDecodeError)):
            json.loads(encrypted_content.decode("utf-8"))

        # Create new DLQ instance with same key - should recover items
        dlq2 = DeadLetterQueue(
            max_size=10, persist_path=self.dlq_path, encryption_key=self.encryption_key
        )

        # Should have recovered items
        self.assertEqual(dlq2.size(), 2)
        self.assertEqual(dlq2._items_recovered, 2)

        # Verify recovered data matches
        recovered_items = dlq2.inspect(limit=10)
        self.assertEqual(len(recovered_items), 2)
        self.assertEqual(recovered_items[0]["item"], test_items[0])
        self.assertEqual(recovered_items[1]["item"], test_items[1])

    def test_encrypted_dlq_cannot_decrypt_with_wrong_key(self):
        """Test encrypted DLQ cannot be decrypted with wrong key."""
        # Create DLQ with one key
        dlq1 = DeadLetterQueue(
            max_size=10, persist_path=self.dlq_path, encryption_key=self.encryption_key
        )
        dlq1.enqueue(item={"data": "secret"}, error=Exception("test"), retry_count=3)

        # Try to load with different key - should fail gracefully
        wrong_key = EncryptionManager.generate_key()
        with self.assertLogs("mca_sdk.resilience.dead_letter_queue", level="WARNING") as log:
            dlq2 = DeadLetterQueue(
                max_size=10, persist_path=self.dlq_path, encryption_key=wrong_key
            )

            # Should start with empty queue (decryption failed)
            self.assertEqual(dlq2.size(), 0)

            # Should log decryption failure
            error_found = any("Failed to decrypt DLQ file" in record for record in log.output)
            self.assertTrue(error_found, "Expected decryption error not found in logs")

    def test_unencrypted_dlq_persists_plaintext(self):
        """Test DLQ without encryption stores plaintext JSON."""
        with patch.dict(os.environ, {}, clear=True):
            dlq = DeadLetterQueue(max_size=10, persist_path=self.dlq_path)

            # Should not have encryption
            self.assertIsNone(dlq._encryption_manager)

            # Enqueue item
            dlq.enqueue(item={"data": "test"}, error=Exception("test"), retry_count=3)

            # File should be readable plaintext JSON
            with open(self.dlq_path, "r", encoding="utf-8") as f:
                data = json.load(f)

            # Should be valid JSON structure (list of DLQ items)
            self.assertIsInstance(data, list)
            self.assertEqual(len(data), 1)
            self.assertIn("item", data[0])
            self.assertIn("error", data[0])
            self.assertIn("timestamp", data[0])

    def test_dlq_handles_invalid_encryption_key(self):
        """Test DLQ handles invalid encryption key gracefully."""
        invalid_key = b"not-a-valid-fernet-key"

        with self.assertLogs("mca_sdk.resilience.dead_letter_queue", level="ERROR") as log:
            dlq = DeadLetterQueue(
                max_size=10, persist_path=self.dlq_path, encryption_key=invalid_key
            )

            # Should fall back to unencrypted
            self.assertIsNone(dlq._encryption_manager)

            # Should log initialization failure
            error_found = any("Failed to initialize encryption" in record for record in log.output)
            self.assertTrue(error_found, "Expected encryption init error not found")

    def test_encrypted_dlq_atomic_write(self):
        """Test encrypted DLQ uses atomic write pattern."""
        dlq = DeadLetterQueue(
            max_size=10, persist_path=self.dlq_path, encryption_key=self.encryption_key
        )

        # Enqueue multiple items
        for i in range(5):
            dlq.enqueue(item={"id": i}, error=Exception(f"error{i}"), retry_count=3)

        # File should exist and be atomic (no temp files left)
        self.assertTrue(os.path.exists(self.dlq_path))

        # Check no temp files remain
        temp_files = [f for f in os.listdir(self.temp_dir) if f.startswith(".dlq_")]
        self.assertEqual(len(temp_files), 0, f"Found leftover temp files: {temp_files}")

    def test_encrypted_dlq_with_large_items(self):
        """Test encrypted DLQ handles large items correctly by truncating them."""
        dlq = DeadLetterQueue(
            max_size=10, persist_path=self.dlq_path, encryption_key=self.encryption_key
        )

        # Create large item (1MB of data) - should be truncated
        large_item = {"data": "x" * (1024 * 1024)}
        dlq.enqueue(item=large_item, error=Exception("test"), retry_count=3)

        # Should persist and recover successfully with truncated item
        dlq2 = DeadLetterQueue(
            max_size=10, persist_path=self.dlq_path, encryption_key=self.encryption_key
        )

        self.assertEqual(dlq2.size(), 1)
        recovered = dlq2.inspect(limit=1)[0]

        # Item should be truncated (not equal to original)
        self.assertNotEqual(recovered["item"], large_item)

        # Should have truncation markers (either early check or post-serialization check)
        self.assertTrue(recovered["item"]["__truncated__"])

        # Check for either new estimated size OR old original size
        if "__estimated_size__" in recovered["item"]:
            self.assertGreater(recovered["item"]["__estimated_size__"], 65536)
            self.assertEqual(recovered["item"]["__reason__"], "Early size check failed")
        else:
            self.assertGreater(recovered["item"]["__original_size_bytes__"], 65536)

    def test_encrypted_dlq_metadata_preserved(self):
        """Test encrypted DLQ preserves all metadata fields."""
        dlq = DeadLetterQueue(
            max_size=10, persist_path=self.dlq_path, encryption_key=self.encryption_key
        )

        test_error = ValueError("test error message")
        dlq.enqueue(item={"test": "data"}, error=test_error, retry_count=5)

        # Recover from disk
        dlq2 = DeadLetterQueue(
            max_size=10, persist_path=self.dlq_path, encryption_key=self.encryption_key
        )

        recovered = dlq2.inspect(limit=1)[0]

        # All metadata should be preserved
        self.assertIn("timestamp", recovered)
        self.assertIn("item", recovered)
        self.assertIn("error", recovered)
        self.assertIn("error_type", recovered)
        self.assertIn("retry_count", recovered)

        self.assertEqual(recovered["error"], "test error message")
        self.assertEqual(recovered["error_type"], "ValueError")
        self.assertEqual(recovered["retry_count"], 5)

    def test_encrypted_dlq_stats_after_recovery(self):
        """Test encrypted DLQ stats are accurate after recovery."""
        # Create DLQ and add items
        dlq1 = DeadLetterQueue(
            max_size=10, persist_path=self.dlq_path, encryption_key=self.encryption_key
        )

        for i in range(3):
            dlq1.enqueue(item={"id": i}, error=Exception(f"e{i}"), retry_count=3)

        # Recover from new instance
        dlq2 = DeadLetterQueue(
            max_size=10, persist_path=self.dlq_path, encryption_key=self.encryption_key
        )

        stats = dlq2.stats()

        # Stats should reflect recovered state
        self.assertEqual(stats["size"], 3)
        self.assertEqual(stats["items_recovered_on_startup"], 3)
        self.assertTrue(stats["encryption_enabled"])

    def test_encryption_manager_in_stats(self):
        """Test stats indicate encryption status correctly."""
        # With encryption
        dlq_encrypted = DeadLetterQueue(
            max_size=10,
            persist_path=os.path.join(self.temp_dir, "encrypted.json"),
            encryption_key=self.encryption_key,
        )

        stats_encrypted = dlq_encrypted.stats()
        self.assertTrue(stats_encrypted["encryption_enabled"])

        # Without encryption
        with patch.dict(os.environ, {}, clear=True):
            dlq_plain = DeadLetterQueue(
                max_size=10, persist_path=os.path.join(self.temp_dir, "plain.json")
            )

            stats_plain = dlq_plain.stats()
            self.assertFalse(stats_plain["encryption_enabled"])

    def test_decryption_failure_preserves_data(self):
        """Test DLQ preserves original file when decryption fails."""
        # Create encrypted DLQ with data
        dlq1 = DeadLetterQueue(
            max_size=10, persist_path=self.dlq_path, encryption_key=self.encryption_key
        )
        dlq1.enqueue(item={"data": "important"}, error=Exception("test"), retry_count=3)

        # Verify original file exists
        self.assertTrue(os.path.exists(self.dlq_path))
        original_size = os.path.getsize(self.dlq_path)

        # Try to load with wrong key - should preserve original
        wrong_key = EncryptionManager.generate_key()

        # Clean up any existing .failed files
        import glob

        failed_pattern = f"{self.dlq_path}.failed.*"
        for f in glob.glob(failed_pattern):
            os.remove(f)

        with self.assertLogs("mca_sdk.resilience.dead_letter_queue", level="WARNING") as log:
            dlq2 = DeadLetterQueue(
                max_size=10, persist_path=self.dlq_path, encryption_key=wrong_key
            )

            # Should start empty (decryption failed)
            self.assertEqual(dlq2.size(), 0)

            # Should have preserved original file
            found_files = glob.glob(failed_pattern)
            self.assertTrue(len(found_files) > 0, f"No backup file found matching {failed_pattern}")
            failed_path = found_files[0]

            # Preserved file should have same content as original
            self.assertEqual(os.path.getsize(failed_path), original_size)

            # Should log preservation message
            preservation_logged = any(
                "Original file preserved at" in record and ".failed" in record
                for record in log.output
            )
            self.assertTrue(preservation_logged, "Expected preservation message not found")

    def test_decryption_failure_handles_backup_error_gracefully(self):
        """Test DLQ handles backup failure gracefully when decryption fails."""
        # Create encrypted DLQ
        dlq1 = DeadLetterQueue(
            max_size=10, persist_path=self.dlq_path, encryption_key=self.encryption_key
        )
        dlq1.enqueue(item={"data": "test"}, error=Exception("test"), retry_count=3)

        wrong_key = EncryptionManager.generate_key()

        # Mock shutil.copy2 to raise an error
        import shutil

        with patch("shutil.copy2", side_effect=OSError("Permission denied")):
            with self.assertLogs("mca_sdk.resilience.dead_letter_queue", level="ERROR") as log:
                dlq2 = DeadLetterQueue(
                    max_size=10, persist_path=self.dlq_path, encryption_key=wrong_key
                )

                # Should start empty
                self.assertEqual(dlq2.size(), 0)

                # Should log backup failure
                backup_error_logged = any(
                    "failed to create backup" in record.lower() for record in log.output
                )
                self.assertTrue(backup_error_logged, "Expected backup error not found")

    def test_dlq_handles_non_serializable_objects(self):
        """Test DLQ handles non-serializable objects gracefully using default=str."""
        import threading

        dlq = DeadLetterQueue(
            max_size=10, persist_path=self.dlq_path, encryption_key=self.encryption_key
        )

        # Create item with non-serializable object (thread lock)
        non_serializable_item = {"lock": threading.Lock(), "data": "test"}

        # Should not crash
        dlq.enqueue(item=non_serializable_item, error=Exception("test"), retry_count=3)

        # Should persist successfully
        self.assertEqual(dlq.size(), 1)

        # Should be able to recover from disk
        dlq2 = DeadLetterQueue(
            max_size=10, persist_path=self.dlq_path, encryption_key=self.encryption_key
        )

        self.assertEqual(dlq2.size(), 1)
        recovered = dlq2.inspect(limit=1)[0]

        # Lock should be converted to string representation
        self.assertIsInstance(recovered["item"]["lock"], str)
        self.assertIn("lock", recovered["item"]["lock"].lower())


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