# #region Test.PreviewResponseParser [C:3] [TYPE Module] [SEMANTICS test, translate, response, parser, data, extract]
# @BRIEF Tests for preview_response_parser.py — parse_llm_response, extract_data_rows, hashes.
# @RELATION BINDS_TO -> [preview_response_parser]

import hashlib
import json
import sys
from pathlib import Path
from unittest.mock import MagicMock, patch

sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent / "src"))

import pytest
from sqlalchemy import create_engine, event
from sqlalchemy.orm import sessionmaker

from src.models.translate import (
    Base, TerminologyDictionary, TranslationJob, TranslationJobDictionary, DictionaryEntry,
)
from src.plugins.translate.preview_response_parser import (
    compute_config_hash,
    compute_dict_snapshot_hash,
    extract_data_rows,
    parse_llm_response,
)


# #region TestParseLlmResponse [C:2] [TYPE Class]
class TestParseLlmResponse:
    """parse_llm_response — parse LLM JSON response into structured translations dict."""

    def test_valid_json_with_rows(self):
        """Happy path: valid JSON with rows."""
        response_text = json.dumps({
            "rows": [
                {"row_id": 1, "detected_source_language": "en",
                 "translation": "hello"},
                {"row_id": 2, "translation": "world"},
            ]
        })
        result = parse_llm_response(response_text, expected_count=2)
        assert "1" in result
        assert "2" in result
        assert result["1"]["translation"] == "hello"
        assert result["2"]["translation"] == "world"
        assert result["2"]["detected_source_language"] == "und"

    def test_json_in_markdown_code_block(self):
        """JSON wrapped in markdown code block."""
        response_text = "```json\n{\"rows\": [{\"row_id\": \"a1\", \"translation\": \"bonjour\"}]}\n```"
        result = parse_llm_response(response_text, expected_count=1)
        assert result["a1"]["translation"] == "bonjour"

    def test_markdown_block_no_json_tag(self):
        """Markdown code block without json tag."""
        response_text = "```\n{\"rows\": [{\"row_id\": \"x\", \"translation\": \"hi\"}]}\n```"
        result = parse_llm_response(response_text, expected_count=1)
        assert result["x"]["translation"] == "hi"

    def test_partial_rows_extracted(self):
        """Recover partial JSON rows when full parse fails."""
        response_text = "Here are translations:\n{\"row_id\": 1, \"translation\": \"hello\"}\n{\"row_id\": 2, \"translation\": \"world\"}\n"
        result = parse_llm_response(response_text, expected_count=2)
        assert result["1"]["translation"] == "hello"
        assert result["2"]["translation"] == "world"

    def test_partial_rows_mixed_validity(self):
        """Skip invalid rows during partial extraction."""
        response_text = "{\"row_id\": 1, \"translation\": \"good\"}\n{invalid json}\n{\"row_id\": 3, \"translation\": \"bad\"}"
        result = parse_llm_response(response_text, expected_count=2)
        assert "1" in result
        assert "3" in result

    def test_not_valid_json_raises(self):
        """Completely invalid JSON raises ValueError."""
        with pytest.raises(ValueError, match="LLM response was not valid JSON"):
            parse_llm_response("not json at all", expected_count=1)

    def test_rows_not_a_list_raises(self):
        """JSON where rows is not a list."""
        response_text = json.dumps({"rows": "not_a_list"})
        with pytest.raises(ValueError, match="missing 'rows' array"):
            parse_llm_response(response_text, expected_count=1)

    def test_skip_empty_row_id(self):
        """Skip rows with no row_id."""
        response_text = json.dumps({
            "rows": [
                {"translation": "no id"},
                {"row_id": 1, "translation": "has id"},
            ]
        })
        result = parse_llm_response(response_text, expected_count=2)
        assert "1" in result
        assert len(result) == 1

    def test_with_target_languages(self):
        """Use target_languages to extract per-language translations."""
        response_text = json.dumps({
            "rows": [
                {"row_id": 1, "fr": "bonjour", "de": "hallo"},
                {"row_id": 2, "fr": "monde"},
            ]
        })
        result = parse_llm_response(response_text, expected_count=2,
                                    target_languages=["fr", "de"])
        assert result["1"]["fr"] == "bonjour"
        assert result["1"]["de"] == "hallo"
        assert result["2"]["fr"] == "monde"
        assert "de" not in result["2"]
        assert "detected_source_language" in result["1"]

    def test_target_languages_empty_value_skips(self):
        """Empty string value in target language is skipped."""
        response_text = json.dumps({
            "rows": [
                {"row_id": 1, "fr": "", "de": "guten tag"},
            ]
        })
        result = parse_llm_response(response_text, expected_count=1,
                                    target_languages=["fr", "de"])
        assert "fr" not in result["1"]
        assert result["1"]["de"] == "guten tag"

    def test_no_language_data_and_no_translation_skips(self):
        """Row with no language data and no translation is skipped."""
        response_text = json.dumps({
            "rows": [
                {"row_id": 1, "fr": None},
            ]
        })
        result = parse_llm_response(response_text, expected_count=1,
                                    target_languages=["fr"])
        assert len(result) == 0

    def test_detected_source_language_none(self):
        """When detected_source_language is None, use 'und'."""
        response_text = json.dumps({
            "rows": [
                {"row_id": 1, "translation": "hello"},
            ]
        })
        result = parse_llm_response(response_text, expected_count=1)
        assert result["1"]["detected_source_language"] == "und"

# #endregion TestParseLlmResponse


# #region TestComputeConfigHash [C:1] [TYPE Class]
class TestComputeConfigHash:
    """compute_config_hash — deterministic hash of job configuration."""

    def test_returns_hex_string(self):
        """Returns a 16-char hex string."""
        job = MagicMock(spec=TranslationJob)
        job.source_dialect = "en"
        job.target_dialect = "fr"
        job.source_datasource_id = 42
        job.translation_column = "text"
        job.context_columns = ["col1", "col2"]
        job.provider_id = "provider1"
        job.batch_size = 100
        job.upsert_strategy = "on_conflict_update"
        result = compute_config_hash(job)
        assert isinstance(result, str)
        assert len(result) == 16
        assert all(c in "0123456789abcdef" for c in result)

    def test_deterministic(self):
        """Same config -> same hash."""
        def _make_job():
            j = MagicMock(spec=TranslationJob)
            j.source_dialect = "en"
            j.target_dialect = "fr"
            j.source_datasource_id = 42
            j.translation_column = "text"
            j.context_columns = ["col1", "col2"]
            j.provider_id = "provider1"
            j.batch_size = 100
            j.upsert_strategy = "on_conflict_update"
            return j
        assert compute_config_hash(_make_job()) == compute_config_hash(_make_job())

    def test_different_config_different_hash(self):
        """Different config -> different hash."""
        def _make_job(dialect):
            j = MagicMock(spec=TranslationJob)
            j.source_dialect = dialect
            j.target_dialect = "fr"
            j.source_datasource_id = 42
            j.translation_column = "text"
            j.context_columns = []
            j.provider_id = "p1"
            j.batch_size = 100
            j.upsert_strategy = "upsert"
            return j
        assert compute_config_hash(_make_job("en")) != compute_config_hash(_make_job("de"))

# #endregion TestComputeConfigHash


# #region TestComputeDictSnapshotHash [C:2] [TYPE Class]
class TestComputeDictSnapshotHash:
    """compute_dict_snapshot_hash — hash of dictionary state for snapshot comparison."""

    @pytest.fixture
    def db_session(self):
        """In-memory SQLite session with translate models."""
        engine = create_engine("sqlite:///:memory:")
        Base.metadata.create_all(bind=engine)
        session = sessionmaker(bind=engine)()
        yield session
        session.close()

    def test_no_dict_links_returns_empty_hash(self, db_session):
        """No dictionary links -> hash of empty bytes."""
        result = compute_dict_snapshot_hash(db_session, "nonexistent-job")
        expected = hashlib.sha256(b"").hexdigest()[:16]
        assert result == expected

    def test_with_dict_links_no_entries(self, db_session):
        """With dict links but no entries."""
        job = TranslationJob(id="j1", name="j1", source_dialect="en", target_dialect="fr", status="ACTIVE")
        db_session.add(job)
        td = TerminologyDictionary(id="d1", name="dict1")
        db_session.add(td)
        link = TranslationJobDictionary(id="l1", job_id="j1", dictionary_id="d1")
        db_session.add(link)
        db_session.commit()

        result = compute_dict_snapshot_hash(db_session, "j1")
        assert isinstance(result, str)
        assert len(result) == 16

    def test_with_dict_links_and_entries(self, db_session):
        """With dict links and entries."""
        job = TranslationJob(id="j2", name="j2", source_dialect="en", target_dialect="fr", status="ACTIVE")
        db_session.add(job)
        td = TerminologyDictionary(id="d2", name="dict2")
        db_session.add(td)
        link = TranslationJobDictionary(id="l2", job_id="j2", dictionary_id="d2")
        db_session.add(link)
        from datetime import UTC, datetime
        entry = DictionaryEntry(id="e1", dictionary_id="d2", source_term="hello",
                                source_term_normalized="hello",
                                target_term="bonjour",
                                source_language="en", target_language="fr",
                                created_at=datetime.now(UTC),
                                updated_at=datetime.now(UTC))
        db_session.add(entry)
        db_session.commit()

        result = compute_dict_snapshot_hash(db_session, "j2")
        assert isinstance(result, str)
        assert len(result) == 16

    def test_deterministic(self, db_session):
        """Same state -> same hash."""
        job = TranslationJob(id="j3", name="j3", source_dialect="en", target_dialect="fr", status="ACTIVE")
        db_session.add(job)
        td = TerminologyDictionary(id="d3", name="dict3")
        db_session.add(td)
        link = TranslationJobDictionary(id="l3", job_id="j3", dictionary_id="d3")
        db_session.add(link)
        db_session.commit()

        h1 = compute_dict_snapshot_hash(db_session, "j3")
        h2 = compute_dict_snapshot_hash(db_session, "j3")
        assert h1 == h2

# #endregion TestComputeDictSnapshotHash


class TestExtractDataRows:
    """extract_data_rows — extract data rows from Superset chart data API response."""

    def test_result_list_with_data(self):
        """result is a list with item containing data key."""
        response = {
            "result": [
                {"data": [{"col1": "val1"}, {"col1": "val2"}]}
            ]
        }
        result = extract_data_rows(response)
        assert len(result) == 2
        assert result[0]["col1"] == "val1"

    def test_result_dict_with_data(self):
        """result is a dict with data key."""
        response = {
            "result": {"data": [{"col1": "val1"}]}
        }
        result = extract_data_rows(response)
        assert len(result) == 1

    def test_fallback_to_response_data(self):
        """Fallback to response.data when result has no data."""
        response = {
            "data": [{"col1": "val1"}]
        }
        result = extract_data_rows(response)
        assert len(result) == 1

    def test_result_list_returned_directly(self):
        """When result is a list with no data key, return it."""
        response = {
            "result": [{"col1": "val1"}, {"col1": "val2"}]
        }
        result = extract_data_rows(response)
        assert len(result) == 2

    def test_empty_result_list(self):
        """Empty result list returns empty list."""
        response = {"result": []}
        result = extract_data_rows(response)
        assert result == []

    def test_empty_data_list(self):
        """Empty data list falls back to returning result list."""
        response = {
            "result": [{"data": []}]
        }
        result = extract_data_rows(response)
        # Returns the result list as fallback
        assert result == [{"data": []}]

    def test_no_result_or_data_key(self):
        """No result or data key returns empty list."""
        response = {"other": "value"}
        result = extract_data_rows(response)
        assert result == []

    def test_result_not_list_or_dict(self):
        """Result is neither list nor dict."""
        response = {"result": "string_value"}
        result = extract_data_rows(response)
        assert result == []

    def test_result_list_item_no_data(self):
        """Result list item has no data key, falls back to result list."""
        response = {
            "result": [{"other": "value"}]
        }
        result = extract_data_rows(response)
        # Returns the result list as fallback
        assert result == [{"other": "value"}]

    def test_result_dict_data_empty_list(self):
        """Result dict data is empty list."""
        response = {
            "result": {"data": []}
        }
        result = extract_data_rows(response)
        assert result == []
