"""
Tests for the vision analysis service (Phase 2B).

VisionAnalyzer tests mock external dependencies (S3, vision model API, Neo4j).
"""

import json
from unittest.mock import MagicMock, patch

from django.test import TestCase

from library.services.vision import VALID_IMAGE_TYPES, VisionAnalyzer


class MockVisionModel:
    """Mock LLMModel for vision testing."""

    def __init__(self):
        self.name = "qwen3-vl-72b"
        self.api = MockVisionApi()


class MockVisionApi:
    """Mock LLMApi for vision testing."""

    def __init__(self):
        self.name = "Local vLLM"
        self.base_url = "http://localhost:8000/v1"
        self.api_key = ""
        self.timeout_seconds = 120


class VisionAnalyzerInitTests(TestCase):
    """Tests for VisionAnalyzer initialization."""

    def test_init_basic(self):
        model = MockVisionModel()
        analyzer = VisionAnalyzer(model)
        self.assertEqual(analyzer.model_name, "qwen3-vl-72b")
        self.assertEqual(analyzer.base_url, "http://localhost:8000/v1")
        self.assertIsNone(analyzer.user)

    def test_init_with_user(self):
        model = MockVisionModel()
        user = MagicMock()
        analyzer = VisionAnalyzer(model, user=user)
        self.assertEqual(analyzer.user, user)

    def test_strips_trailing_slash_from_base_url(self):
        model = MockVisionModel()
        model.api.base_url = "http://localhost:8000/v1/"
        analyzer = VisionAnalyzer(model)
        self.assertEqual(analyzer.base_url, "http://localhost:8000/v1")


class VisionResponseParsingTests(TestCase):
    """Tests for _parse_vision_response."""

    def setUp(self):
        self.analyzer = VisionAnalyzer(MockVisionModel())

    def test_parse_valid_json(self):
        response = json.dumps({
            "image_type": "diagram",
            "description": "A wiring diagram.",
            "ocr_text": "L1 L2 L3",
            "concepts": [{"name": "wiring", "type": "topic"}],
        })
        result = self.analyzer._parse_vision_response(response)
        self.assertIsNotNone(result)
        self.assertEqual(result["image_type"], "diagram")
        self.assertEqual(result["description"], "A wiring diagram.")
        self.assertEqual(result["ocr_text"], "L1 L2 L3")
        self.assertEqual(len(result["concepts"]), 1)

    def test_parse_json_in_markdown_code_block(self):
        response = '```json\n{"image_type": "chart", "description": "A pie chart.", "ocr_text": "", "concepts": []}\n```'
        result = self.analyzer._parse_vision_response(response)
        self.assertIsNotNone(result)
        self.assertEqual(result["image_type"], "chart")

    def test_parse_json_embedded_in_text(self):
        response = 'Here is my analysis: {"image_type": "photo", "description": "A landscape.", "ocr_text": "", "concepts": []} I hope that helps.'
        result = self.analyzer._parse_vision_response(response)
        self.assertIsNotNone(result)
        self.assertEqual(result["image_type"], "photo")

    def test_parse_invalid_json_returns_none(self):
        response = "This is not JSON at all, just a description."
        result = self.analyzer._parse_vision_response(response)
        self.assertIsNone(result)

    def test_parse_empty_string_returns_none(self):
        result = self.analyzer._parse_vision_response("")
        self.assertIsNone(result)

    def test_parse_json_array_extracts_object_via_regex(self):
        """JSON arrays containing an object get extracted via regex fallback."""
        response = '[{"image_type": "photo", "description": "Test", "ocr_text": "", "concepts": []}]'
        result = self.analyzer._parse_vision_response(response)
        # The regex fallback finds the embedded JSON object
        self.assertIsNotNone(result)
        self.assertEqual(result["image_type"], "photo")


class VisionResultValidationTests(TestCase):
    """Tests for _validate_result."""

    def setUp(self):
        self.analyzer = VisionAnalyzer(MockVisionModel())

    def test_valid_image_type_preserved(self):
        result = self.analyzer._validate_result({
            "image_type": "diagram",
            "description": "A diagram.",
            "ocr_text": "",
            "concepts": [],
        })
        self.assertEqual(result["image_type"], "diagram")

    def test_invalid_image_type_defaults_to_photo(self):
        result = self.analyzer._validate_result({
            "image_type": "unknown_type",
            "description": "Something.",
            "ocr_text": "",
            "concepts": [],
        })
        self.assertEqual(result["image_type"], "photo")

    def test_empty_image_type_defaults_to_photo(self):
        result = self.analyzer._validate_result({
            "image_type": "",
            "description": "Something.",
            "ocr_text": "",
            "concepts": [],
        })
        self.assertEqual(result["image_type"], "photo")

    def test_image_type_case_insensitive(self):
        result = self.analyzer._validate_result({
            "image_type": "DIAGRAM",
            "description": "Something.",
            "ocr_text": "",
            "concepts": [],
        })
        self.assertEqual(result["image_type"], "diagram")

    def test_all_valid_image_types(self):
        for image_type in VALID_IMAGE_TYPES:
            result = self.analyzer._validate_result({
                "image_type": image_type,
                "description": "Test.",
                "ocr_text": "",
                "concepts": [],
            })
            self.assertEqual(result["image_type"], image_type)

    def test_description_truncated_at_2000(self):
        long_desc = "x" * 3000
        result = self.analyzer._validate_result({
            "image_type": "photo",
            "description": long_desc,
            "ocr_text": "",
            "concepts": [],
        })
        self.assertEqual(len(result["description"]), 2000)

    def test_ocr_text_truncated_at_5000(self):
        long_ocr = "y" * 6000
        result = self.analyzer._validate_result({
            "image_type": "photo",
            "description": "Test.",
            "ocr_text": long_ocr,
            "concepts": [],
        })
        self.assertEqual(len(result["ocr_text"]), 5000)

    def test_concepts_capped_at_20(self):
        concepts = [{"name": f"concept-{i}", "type": "topic"} for i in range(30)]
        result = self.analyzer._validate_result({
            "image_type": "photo",
            "description": "Test.",
            "ocr_text": "",
            "concepts": concepts,
        })
        self.assertEqual(len(result["concepts"]), 20)

    def test_concept_names_lowercased(self):
        result = self.analyzer._validate_result({
            "image_type": "photo",
            "description": "Test.",
            "ocr_text": "",
            "concepts": [{"name": "Machine Learning", "type": "topic"}],
        })
        self.assertEqual(result["concepts"][0]["name"], "machine learning")

    def test_concept_names_stripped(self):
        result = self.analyzer._validate_result({
            "image_type": "photo",
            "description": "Test.",
            "ocr_text": "",
            "concepts": [{"name": "  padded  ", "type": "topic"}],
        })
        self.assertEqual(result["concepts"][0]["name"], "padded")

    def test_short_concept_names_filtered(self):
        """Concept names shorter than 2 characters are filtered out."""
        result = self.analyzer._validate_result({
            "image_type": "photo",
            "description": "Test.",
            "ocr_text": "",
            "concepts": [
                {"name": "a", "type": "topic"},
                {"name": "ab", "type": "topic"},
            ],
        })
        self.assertEqual(len(result["concepts"]), 1)
        self.assertEqual(result["concepts"][0]["name"], "ab")

    def test_invalid_concept_entries_filtered(self):
        """Non-dict entries and entries without name are filtered."""
        result = self.analyzer._validate_result({
            "image_type": "photo",
            "description": "Test.",
            "ocr_text": "",
            "concepts": [
                "just a string",
                {"type": "topic"},  # missing name
                {"name": "valid", "type": "topic"},
            ],
        })
        self.assertEqual(len(result["concepts"]), 1)
        self.assertEqual(result["concepts"][0]["name"], "valid")

    def test_missing_concept_type_defaults_to_topic(self):
        result = self.analyzer._validate_result({
            "image_type": "photo",
            "description": "Test.",
            "ocr_text": "",
            "concepts": [{"name": "untyped"}],
        })
        self.assertEqual(result["concepts"][0]["type"], "topic")

    def test_missing_fields_default_to_empty(self):
        result = self.analyzer._validate_result({})
        self.assertEqual(result["image_type"], "photo")
        self.assertEqual(result["description"], "")
        self.assertEqual(result["ocr_text"], "")
        self.assertEqual(result["concepts"], [])


class VisionModelCallTests(TestCase):
    """Tests for _call_vision_model HTTP request construction."""

    def setUp(self):
        self.analyzer = VisionAnalyzer(MockVisionModel())

    @patch("library.services.vision.requests.post")
    def test_call_sends_correct_payload(self, mock_post):
        mock_response = MagicMock()
        mock_response.status_code = 200
        mock_response.json.return_value = {
            "choices": [
                {"message": {"content": '{"image_type": "photo", "description": "Test", "ocr_text": "", "concepts": []}'}}
            ]
        }
        mock_post.return_value = mock_response

        result = self.analyzer._call_vision_model(
            b64_image="dGVzdA==",
            mime_type="image/png",
            user_prompt="Analyze this image.",
        )

        mock_post.assert_called_once()
        call_args = mock_post.call_args
        url = call_args[0][0] if call_args[0] else call_args[1].get("url")
        self.assertIn("/chat/completions", url)

        body = call_args[1]["json"]
        self.assertEqual(body["model"], "qwen3-vl-72b")
        self.assertEqual(body["temperature"], 0.1)
        self.assertEqual(body["max_tokens"], 800)
        self.assertEqual(len(body["messages"]), 2)
        self.assertEqual(body["messages"][0]["role"], "system")
        self.assertEqual(body["messages"][1]["role"], "user")

        # User message should contain text and image_url parts
        user_content = body["messages"][1]["content"]
        self.assertEqual(len(user_content), 2)
        self.assertEqual(user_content[0]["type"], "text")
        self.assertEqual(user_content[1]["type"], "image_url")
        self.assertIn("data:image/png;base64,", user_content[1]["image_url"]["url"])

    @patch("library.services.vision.requests.post")
    def test_call_includes_auth_header_when_api_key_set(self, mock_post):
        self.analyzer.api.api_key = "test-key-123"
        mock_response = MagicMock()
        mock_response.status_code = 200
        mock_response.json.return_value = {
            "choices": [{"message": {"content": "{}"}}]
        }
        mock_post.return_value = mock_response

        self.analyzer._call_vision_model("dGVzdA==", "image/png", "Test")

        call_headers = mock_post.call_args[1]["headers"]
        self.assertIn("Authorization", call_headers)
        self.assertEqual(call_headers["Authorization"], "Bearer test-key-123")

    @patch("library.services.vision.requests.post")
    def test_call_no_auth_header_when_no_api_key(self, mock_post):
        self.analyzer.api.api_key = ""
        mock_response = MagicMock()
        mock_response.status_code = 200
        mock_response.json.return_value = {
            "choices": [{"message": {"content": "{}"}}]
        }
        mock_post.return_value = mock_response

        self.analyzer._call_vision_model("dGVzdA==", "image/png", "Test")

        call_headers = mock_post.call_args[1]["headers"]
        self.assertNotIn("Authorization", call_headers)

    @patch("library.services.vision.requests.post")
    def test_call_parses_openai_format(self, mock_post):
        mock_response = MagicMock()
        mock_response.status_code = 200
        mock_response.json.return_value = {
            "choices": [{"message": {"content": "response text"}}]
        }
        mock_post.return_value = mock_response

        result = self.analyzer._call_vision_model("dGVzdA==", "image/png", "Test")
        self.assertEqual(result, "response text")

    @patch("library.services.vision.requests.post")
    def test_call_parses_bedrock_format(self, mock_post):
        mock_response = MagicMock()
        mock_response.status_code = 200
        mock_response.json.return_value = {
            "output": {"message": {"content": [{"text": "bedrock response"}]}}
        }
        mock_post.return_value = mock_response

        result = self.analyzer._call_vision_model("dGVzdA==", "image/png", "Test")
        self.assertEqual(result, "bedrock response")

    @patch("library.services.vision.requests.post")
    def test_call_raises_on_unexpected_format(self, mock_post):
        mock_response = MagicMock()
        mock_response.status_code = 200
        mock_response.json.return_value = {"unexpected": "format"}
        mock_post.return_value = mock_response

        with self.assertRaises(ValueError):
            self.analyzer._call_vision_model("dGVzdA==", "image/png", "Test")

    @patch("library.services.vision.requests.post")
    def test_call_raises_on_http_error(self, mock_post):
        mock_response = MagicMock()
        mock_response.status_code = 500
        mock_response.text = "Internal Server Error"
        mock_response.raise_for_status.side_effect = Exception("500 Server Error")
        mock_post.return_value = mock_response

        with self.assertRaises(Exception):
            self.analyzer._call_vision_model("dGVzdA==", "image/png", "Test")


class AnalyzeImagesTests(TestCase):
    """Tests for the top-level analyze_images method."""

    def setUp(self):
        self.analyzer = VisionAnalyzer(MockVisionModel())

    @patch.object(VisionAnalyzer, "_log_usage")
    @patch.object(VisionAnalyzer, "_apply_result")
    @patch.object(VisionAnalyzer, "_analyze_single_image")
    @patch("django.core.files.storage.default_storage")
    def test_successful_analysis_returns_count(
        self, mock_storage, mock_analyze, mock_apply, mock_log
    ):
        mock_file = MagicMock()
        mock_file.read.return_value = b"fake image data"
        mock_storage.open.return_value = mock_file

        mock_analyze.return_value = {
            "image_type": "diagram",
            "description": "Test",
            "ocr_text": "",
            "concepts": [],
        }

        img_node = MagicMock()
        img_node.s3_key = "images/test/0.png"

        count = self.analyzer.analyze_images([img_node], vision_prompt="Analyze")

        self.assertEqual(count, 1)
        mock_analyze.assert_called_once()
        mock_apply.assert_called_once()

    @patch.object(VisionAnalyzer, "_analyze_single_image")
    @patch("django.core.files.storage.default_storage")
    def test_failed_analysis_marks_failed(self, mock_storage, mock_analyze):
        mock_file = MagicMock()
        mock_file.read.return_value = b"fake image data"
        mock_storage.open.return_value = mock_file

        mock_analyze.return_value = None  # Analysis failed

        img_node = MagicMock()
        img_node.s3_key = "images/test/0.png"

        count = self.analyzer.analyze_images([img_node])

        self.assertEqual(count, 0)
        self.assertEqual(img_node.analysis_status, "failed")
        img_node.save.assert_called()

    @patch("django.core.files.storage.default_storage")
    def test_s3_read_failure_marks_failed(self, mock_storage):
        mock_storage.open.side_effect = Exception("S3 error")

        img_node = MagicMock()
        img_node.s3_key = "images/test/0.png"

        count = self.analyzer.analyze_images([img_node])

        self.assertEqual(count, 0)
        self.assertEqual(img_node.analysis_status, "failed")

    @patch.object(VisionAnalyzer, "_log_usage")
    @patch.object(VisionAnalyzer, "_apply_result")
    @patch.object(VisionAnalyzer, "_analyze_single_image")
    @patch("django.core.files.storage.default_storage")
    def test_partial_failure_counts_successes(
        self, mock_storage, mock_analyze, mock_apply, mock_log
    ):
        """One success + one failure = count of 1."""
        mock_file = MagicMock()
        mock_file.read.return_value = b"fake data"
        mock_storage.open.return_value = mock_file

        # First succeeds, second fails
        mock_analyze.side_effect = [
            {"image_type": "photo", "description": "Test", "ocr_text": "", "concepts": []},
            None,
        ]

        img1 = MagicMock()
        img1.s3_key = "images/test/0.png"
        img2 = MagicMock()
        img2.s3_key = "images/test/1.png"

        count = self.analyzer.analyze_images([img1, img2])

        self.assertEqual(count, 1)

    def test_empty_image_list(self):
        count = self.analyzer.analyze_images([])
        self.assertEqual(count, 0)

    @patch.object(VisionAnalyzer, "_log_usage")
    @patch.object(VisionAnalyzer, "_apply_result")
    @patch.object(VisionAnalyzer, "_analyze_single_image")
    @patch("django.core.files.storage.default_storage")
    def test_extracts_extension_from_s3_key(
        self, mock_storage, mock_analyze, mock_apply, mock_log
    ):
        mock_file = MagicMock()
        mock_file.read.return_value = b"data"
        mock_storage.open.return_value = mock_file

        mock_analyze.return_value = {
            "image_type": "photo",
            "description": "Test",
            "ocr_text": "",
            "concepts": [],
        }

        img_node = MagicMock()
        img_node.s3_key = "images/test/0.jpg"

        self.analyzer.analyze_images([img_node])

        # Check the extension passed to _analyze_single_image
        call_args = mock_analyze.call_args
        self.assertEqual(call_args[0][1], "jpg")  # ext argument
