"""
tests/track_a/test_story_5_10.py

Story 5.10 — FAQWorker: Static FAQ Lookup

Black-box tests  (BB1–BB3): validate external behaviour via public API
White-box tests  (WB1–WB3): validate internal implementation details

All external dependencies are fully mocked.
No real Redis. No network. No SQLite.
All tests use tmp_path for FAQ file injection.
"""

import sys
sys.path.insert(0, "/mnt/e/genesis-system")

import asyncio
import difflib
import json
import pytest
from datetime import datetime
from pathlib import Path
from unittest.mock import MagicMock, call

from core.intent.intent_signal import IntentType, IntentSignal
from core.workers.faq_worker import (
    FAQWorker,
    FALLBACK_ANSWER,
    MATCH_THRESHOLD,
)


# ---------------------------------------------------------------------------
# Test fixtures and helpers
# ---------------------------------------------------------------------------

_SAMPLE_FAQS = [
    {"question": "What are your business hours?", "answer": "We're available 24/7 through our AI receptionist."},
    {"question": "How much do you charge?", "answer": "Pricing depends on the service. We'll provide a free quote."},
    {"question": "Do you service my area?", "answer": "We service the greater Brisbane, Sydney, and Melbourne areas."},
]


def _write_faq_file(path: Path, entries: list[dict]) -> Path:
    """Write FAQ entries to a JSON file and return the path."""
    path.write_text(json.dumps(entries), encoding="utf-8")
    return path


def _make_signal(
    utterance: str = "What are your business hours?",
    session_id: str = "test-session-510",
) -> IntentSignal:
    """Factory for an IntentSignal with ANSWER_FAQ intent."""
    return IntentSignal(
        session_id=session_id,
        utterance=utterance,
        intent_type=IntentType.ANSWER_FAQ,
        confidence=0.95,
        extracted_entities={},
        requires_swarm=False,
        created_at=datetime(2026, 2, 25, 12, 0, 0),
        raw_gemini_response=None,
    )


def _make_redis_client() -> MagicMock:
    """Build a mock Redis client with an hset method."""
    mock_redis = MagicMock(name="RedisClient")
    mock_redis.hset = MagicMock(return_value=1)
    return mock_redis


# ---------------------------------------------------------------------------
# BB1: Known FAQ question → matched=True, answer returned
# ---------------------------------------------------------------------------

class TestBB1_KnownFAQMatched:
    """BB1 — A question that closely matches a FAQ entry returns matched=True."""

    @pytest.mark.asyncio
    async def test_returns_dict(self, tmp_path):
        faq_file = _write_faq_file(tmp_path / "faq.json", _SAMPLE_FAQS)
        worker = FAQWorker(faq_path=faq_file, redis_client=_make_redis_client())
        result = await worker.execute(_make_signal("What are your business hours?"))
        assert isinstance(result, dict)

    @pytest.mark.asyncio
    async def test_matched_flag_is_true(self, tmp_path):
        faq_file = _write_faq_file(tmp_path / "faq.json", _SAMPLE_FAQS)
        worker = FAQWorker(faq_path=faq_file, redis_client=_make_redis_client())
        result = await worker.execute(_make_signal("What are your business hours?"))
        assert result["matched"] is True

    @pytest.mark.asyncio
    async def test_returns_correct_answer(self, tmp_path):
        faq_file = _write_faq_file(tmp_path / "faq.json", _SAMPLE_FAQS)
        worker = FAQWorker(faq_path=faq_file, redis_client=_make_redis_client())
        result = await worker.execute(_make_signal("What are your business hours?"))
        assert result["answer"] == "We're available 24/7 through our AI receptionist."

    @pytest.mark.asyncio
    async def test_second_faq_question_matched(self, tmp_path):
        faq_file = _write_faq_file(tmp_path / "faq.json", _SAMPLE_FAQS)
        worker = FAQWorker(faq_path=faq_file, redis_client=_make_redis_client())
        result = await worker.execute(_make_signal("How much do you charge?"))
        assert result["matched"] is True
        assert "free quote" in result["answer"]

    @pytest.mark.asyncio
    async def test_result_has_answer_and_matched_keys(self, tmp_path):
        faq_file = _write_faq_file(tmp_path / "faq.json", _SAMPLE_FAQS)
        worker = FAQWorker(faq_path=faq_file, redis_client=_make_redis_client())
        result = await worker.execute(_make_signal("What are your business hours?"))
        assert "answer" in result
        assert "matched" in result

    @pytest.mark.asyncio
    async def test_answer_is_string(self, tmp_path):
        faq_file = _write_faq_file(tmp_path / "faq.json", _SAMPLE_FAQS)
        worker = FAQWorker(faq_path=faq_file, redis_client=_make_redis_client())
        result = await worker.execute(_make_signal("What are your business hours?"))
        assert isinstance(result["answer"], str)
        assert len(result["answer"]) > 0


# ---------------------------------------------------------------------------
# BB2: Unknown question → matched=False, fallback answer returned
# ---------------------------------------------------------------------------

class TestBB2_UnknownQuestionFallback:
    """BB2 — An utterance with no close FAQ match returns matched=False and fallback."""

    @pytest.mark.asyncio
    async def test_unrelated_utterance_matched_false(self, tmp_path):
        faq_file = _write_faq_file(tmp_path / "faq.json", _SAMPLE_FAQS)
        worker = FAQWorker(faq_path=faq_file, redis_client=_make_redis_client())
        result = await worker.execute(_make_signal("xyzzy lorem ipsum nonsense gibberish 12345"))
        assert result["matched"] is False

    @pytest.mark.asyncio
    async def test_unrelated_utterance_returns_fallback_answer(self, tmp_path):
        faq_file = _write_faq_file(tmp_path / "faq.json", _SAMPLE_FAQS)
        worker = FAQWorker(faq_path=faq_file, redis_client=_make_redis_client())
        result = await worker.execute(_make_signal("xyzzy lorem ipsum nonsense gibberish 12345"))
        assert result["answer"] == FALLBACK_ANSWER

    @pytest.mark.asyncio
    async def test_empty_faq_file_returns_fallback(self, tmp_path):
        faq_file = _write_faq_file(tmp_path / "faq.json", [])
        worker = FAQWorker(faq_path=faq_file, redis_client=_make_redis_client())
        result = await worker.execute(_make_signal("What are your hours?"))
        assert result["matched"] is False
        assert result["answer"] == FALLBACK_ANSWER

    @pytest.mark.asyncio
    async def test_missing_faq_file_returns_fallback(self, tmp_path):
        """Missing FAQ file must not crash — return fallback gracefully."""
        nonexistent_path = tmp_path / "does_not_exist.json"
        worker = FAQWorker(faq_path=nonexistent_path, redis_client=_make_redis_client())
        result = await worker.execute(_make_signal("What are your hours?"))
        assert result["matched"] is False
        assert result["answer"] == FALLBACK_ANSWER

    @pytest.mark.asyncio
    async def test_empty_utterance_returns_fallback(self, tmp_path):
        """Empty utterance should not match any FAQ entry."""
        faq_file = _write_faq_file(tmp_path / "faq.json", _SAMPLE_FAQS)
        worker = FAQWorker(faq_path=faq_file, redis_client=_make_redis_client())
        result = await worker.execute(_make_signal(utterance=""))
        # Empty string may or may not match — result must always be well-formed
        assert "matched" in result
        assert isinstance(result["answer"], str)

    @pytest.mark.asyncio
    async def test_malformed_json_file_returns_fallback(self, tmp_path):
        """A malformed JSON FAQ file must not crash execute()."""
        bad_file = tmp_path / "bad.json"
        bad_file.write_text("{ not valid json !!!", encoding="utf-8")
        worker = FAQWorker(faq_path=bad_file, redis_client=_make_redis_client())
        result = await worker.execute(_make_signal("What are your hours?"))
        assert result["matched"] is False
        assert result["answer"] == FALLBACK_ANSWER


# ---------------------------------------------------------------------------
# BB3: Answer written to Redis aiva:state:{session_id} under faq_answer
# ---------------------------------------------------------------------------

class TestBB3_RedisWrite:
    """BB3 — Answer is written to Redis aiva:state:{session_id}[faq_answer]."""

    @pytest.mark.asyncio
    async def test_redis_hset_called_on_match(self, tmp_path):
        faq_file = _write_faq_file(tmp_path / "faq.json", _SAMPLE_FAQS)
        redis = _make_redis_client()
        worker = FAQWorker(faq_path=faq_file, redis_client=redis)
        await worker.execute(_make_signal("What are your business hours?", "sess-match"))
        redis.hset.assert_called_once()

    @pytest.mark.asyncio
    async def test_redis_key_includes_session_id(self, tmp_path):
        faq_file = _write_faq_file(tmp_path / "faq.json", _SAMPLE_FAQS)
        redis = _make_redis_client()
        worker = FAQWorker(faq_path=faq_file, redis_client=redis)
        await worker.execute(_make_signal("What are your business hours?", "sess-abc"))
        key_arg = redis.hset.call_args[0][0]
        assert key_arg == "aiva:state:sess-abc"

    @pytest.mark.asyncio
    async def test_redis_field_is_faq_answer(self, tmp_path):
        faq_file = _write_faq_file(tmp_path / "faq.json", _SAMPLE_FAQS)
        redis = _make_redis_client()
        worker = FAQWorker(faq_path=faq_file, redis_client=redis)
        await worker.execute(_make_signal("What are your business hours?", "sess-abc"))
        field_arg = redis.hset.call_args[0][1]
        assert field_arg == "faq_answer"

    @pytest.mark.asyncio
    async def test_redis_value_is_matched_answer(self, tmp_path):
        faq_file = _write_faq_file(tmp_path / "faq.json", _SAMPLE_FAQS)
        redis = _make_redis_client()
        worker = FAQWorker(faq_path=faq_file, redis_client=redis)
        await worker.execute(_make_signal("What are your business hours?", "sess-xyz"))
        value_arg = redis.hset.call_args[0][2]
        assert value_arg == "We're available 24/7 through our AI receptionist."

    @pytest.mark.asyncio
    async def test_fallback_answer_also_written_to_redis(self, tmp_path):
        """Even on no-match, fallback answer must still be written to Redis."""
        faq_file = _write_faq_file(tmp_path / "faq.json", _SAMPLE_FAQS)
        redis = _make_redis_client()
        worker = FAQWorker(faq_path=faq_file, redis_client=redis)
        await worker.execute(_make_signal("xyzzy lorem ipsum nonsense gibberish 12345", "sess-fallback"))
        redis.hset.assert_called_once()
        value_arg = redis.hset.call_args[0][2]
        assert value_arg == FALLBACK_ANSWER

    @pytest.mark.asyncio
    async def test_no_redis_client_does_not_crash(self, tmp_path):
        """No redis_client must not cause execute() to crash."""
        faq_file = _write_faq_file(tmp_path / "faq.json", _SAMPLE_FAQS)
        worker = FAQWorker(faq_path=faq_file, redis_client=None)
        result = await worker.execute(_make_signal("What are your business hours?"))
        assert isinstance(result, dict)
        assert "answer" in result


# ---------------------------------------------------------------------------
# WB1: difflib.SequenceMatcher used with ratio > 0.7 threshold
# ---------------------------------------------------------------------------

class TestWB1_DifflibMatchThreshold:
    """WB1 — SequenceMatcher ratio must exceed MATCH_THRESHOLD (0.7) for a match."""

    def test_match_threshold_constant_is_0_7(self):
        """Module-level MATCH_THRESHOLD must be exactly 0.7."""
        assert MATCH_THRESHOLD == 0.7

    def test_find_best_match_uses_difflib_ratio(self, tmp_path):
        """_find_best_match returns ratio consistent with difflib.SequenceMatcher."""
        worker = FAQWorker(faq_path=tmp_path / "unused.json", redis_client=None)
        faqs = [{"question": "What are your business hours?", "answer": "24/7"}]

        utterance = "What are your business hours?"
        _, score = worker._find_best_match(utterance, faqs)

        # Score for identical strings should be exactly 1.0
        expected = difflib.SequenceMatcher(
            None,
            utterance.lower().strip(),
            "what are your business hours?",
        ).ratio()
        assert abs(score - expected) < 1e-9

    def test_dissimilar_strings_score_below_threshold(self, tmp_path):
        """Completely different strings must score below MATCH_THRESHOLD."""
        worker = FAQWorker(faq_path=tmp_path / "unused.json", redis_client=None)
        faqs = [{"question": "What are your business hours?", "answer": "24/7"}]
        _, score = worker._find_best_match("aardvark banana helicopter zephyr", faqs)
        assert score < MATCH_THRESHOLD

    def test_identical_string_scores_1_0(self, tmp_path):
        """An utterance identical to the FAQ question must score 1.0."""
        worker = FAQWorker(faq_path=tmp_path / "unused.json", redis_client=None)
        faqs = [{"question": "What are your business hours?", "answer": "24/7"}]
        _, score = worker._find_best_match("What are your business hours?", faqs)
        assert abs(score - 1.0) < 1e-9

    @pytest.mark.asyncio
    async def test_score_just_below_threshold_returns_fallback(self, tmp_path):
        """A score just below 0.7 must trigger the fallback path."""
        # Create a FAQ question that will score just below threshold with our test input
        faqs = [{"question": "Completely unrelated topic about something else entirely", "answer": "Should not appear"}]
        faq_file = _write_faq_file(tmp_path / "faq.json", faqs)
        worker = FAQWorker(faq_path=faq_file, redis_client=_make_redis_client())

        result = await worker.execute(_make_signal("What are your business hours?"))
        # Score for these two dissimilar strings will be below 0.7
        assert result["matched"] is False
        assert result["answer"] == FALLBACK_ANSWER


# ---------------------------------------------------------------------------
# WB2: faq_answer key written to Redis
# ---------------------------------------------------------------------------

class TestWB2_RedisFieldName:
    """WB2 — Redis hset must always use 'faq_answer' as the field name."""

    @pytest.mark.asyncio
    async def test_redis_field_name_on_match(self, tmp_path):
        faq_file = _write_faq_file(tmp_path / "faq.json", _SAMPLE_FAQS)
        redis = _make_redis_client()
        worker = FAQWorker(faq_path=faq_file, redis_client=redis)
        await worker.execute(_make_signal("What are your business hours?", "sess-wb2-match"))
        field_arg = redis.hset.call_args[0][1]
        assert field_arg == "faq_answer"

    @pytest.mark.asyncio
    async def test_redis_field_name_on_fallback(self, tmp_path):
        """Fallback path must also use 'faq_answer' as the Redis field name."""
        faq_file = _write_faq_file(tmp_path / "faq.json", _SAMPLE_FAQS)
        redis = _make_redis_client()
        worker = FAQWorker(faq_path=faq_file, redis_client=redis)
        await worker.execute(_make_signal("xyzzy lorem ipsum nonsense gibberish", "sess-wb2-fallback"))
        field_arg = redis.hset.call_args[0][1]
        assert field_arg == "faq_answer"

    @pytest.mark.asyncio
    async def test_redis_hset_called_exactly_once_per_execute(self, tmp_path):
        """execute() must call hset exactly once regardless of match outcome."""
        faq_file = _write_faq_file(tmp_path / "faq.json", _SAMPLE_FAQS)
        redis = _make_redis_client()
        worker = FAQWorker(faq_path=faq_file, redis_client=redis)
        await worker.execute(_make_signal("What are your business hours?"))
        assert redis.hset.call_count == 1

    @pytest.mark.asyncio
    async def test_two_executes_call_hset_twice(self, tmp_path):
        """Two sequential executes must call hset exactly twice."""
        faq_file = _write_faq_file(tmp_path / "faq.json", _SAMPLE_FAQS)
        redis = _make_redis_client()
        worker = FAQWorker(faq_path=faq_file, redis_client=redis)
        await worker.execute(_make_signal("What are your business hours?", "sess-1"))
        await worker.execute(_make_signal("How much do you charge?", "sess-2"))
        assert redis.hset.call_count == 2


# ---------------------------------------------------------------------------
# WB3: FAQ JSON file loaded per execute() call
# ---------------------------------------------------------------------------

class TestWB3_FaqFileLoadedPerExecute:
    """WB3 — FAQ JSON file is loaded fresh on every execute() call."""

    @pytest.mark.asyncio
    async def test_faq_file_loaded_via_load_faq(self, tmp_path):
        """_load_faq() must parse the JSON file and return a list of dicts."""
        faq_file = _write_faq_file(tmp_path / "faq.json", _SAMPLE_FAQS)
        worker = FAQWorker(faq_path=faq_file, redis_client=None)
        entries = worker._load_faq()
        assert isinstance(entries, list)
        assert len(entries) == len(_SAMPLE_FAQS)

    @pytest.mark.asyncio
    async def test_load_faq_returns_dicts_with_question_answer(self, tmp_path):
        """Each entry from _load_faq must have 'question' and 'answer' keys."""
        faq_file = _write_faq_file(tmp_path / "faq.json", _SAMPLE_FAQS)
        worker = FAQWorker(faq_path=faq_file, redis_client=None)
        entries = worker._load_faq()
        for entry in entries:
            assert "question" in entry
            assert "answer" in entry

    @pytest.mark.asyncio
    async def test_execute_sees_updated_faq_file(self, tmp_path):
        """If the FAQ file is updated between calls, the second call uses new data."""
        faq_file = tmp_path / "faq.json"

        # First call: original FAQs
        _write_faq_file(faq_file, _SAMPLE_FAQS)
        worker = FAQWorker(faq_path=faq_file, redis_client=_make_redis_client())
        result_1 = await worker.execute(_make_signal("What are your business hours?"))
        assert result_1["matched"] is True

        # Update file with different FAQ entries
        new_faqs = [
            {"question": "Are robots cool?", "answer": "Absolutely, robots are awesome."}
        ]
        _write_faq_file(faq_file, new_faqs)

        # Second call: should now not match business hours
        result_2 = await worker.execute(_make_signal("What are your business hours?"))
        # The new FAQ has no business hours entry → should fallback
        assert result_2["matched"] is False

    def test_load_faq_returns_empty_list_on_missing_file(self, tmp_path):
        """_load_faq() must return [] when the file does not exist."""
        worker = FAQWorker(faq_path=tmp_path / "missing.json", redis_client=None)
        entries = worker._load_faq()
        assert entries == []

    def test_load_faq_returns_empty_list_on_non_array_json(self, tmp_path):
        """_load_faq() must return [] when the file contains a non-array JSON value."""
        bad_file = tmp_path / "obj.json"
        bad_file.write_text(json.dumps({"key": "value"}), encoding="utf-8")
        worker = FAQWorker(faq_path=bad_file, redis_client=None)
        entries = worker._load_faq()
        assert entries == []
