"""Comprehensive tests for Levenshtein distance and fuzzy matching (src/rbsp/core/levenshtein.py)."""

import os
import sys
import unittest

sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'src'))
from rbsp.core.levenshtein import (
    edit_distance,
    edit_distance_threshold,
    damerau_levenshtein,
    DamerauLevenshtein,
    FuzzyMatcher,
)


# ---------------------------------------------------------------------------
# edit_distance
# ---------------------------------------------------------------------------

class TestEditDistance(unittest.TestCase):
    """Classic Levenshtein distance."""

    def test_identical(self):
        self.assertEqual(edit_distance("kitten", "kitten"), 0)

    def test_empty_both(self):
        self.assertEqual(edit_distance("", ""), 0)

    def test_empty_left(self):
        self.assertEqual(edit_distance("", "abc"), 3)

    def test_empty_right(self):
        self.assertEqual(edit_distance("abc", ""), 3)

    def test_single_insert(self):
        self.assertEqual(edit_distance("abc", "abcd"), 1)

    def test_single_delete(self):
        self.assertEqual(edit_distance("abcd", "abc"), 1)

    def test_single_substitute(self):
        self.assertEqual(edit_distance("abc", "axc"), 1)

    def test_classic_kitten_sitting(self):
        self.assertEqual(edit_distance("kitten", "sitting"), 3)

    def test_classic_saturday_sunday(self):
        self.assertEqual(edit_distance("saturday", "sunday"), 3)

    def test_completely_different(self):
        self.assertEqual(edit_distance("abc", "xyz"), 3)

    def test_symmetry(self):
        self.assertEqual(edit_distance("abc", "ca"), edit_distance("ca", "abc"))

    def test_single_char(self):
        self.assertEqual(edit_distance("a", "b"), 1)

    def test_prefix(self):
        self.assertEqual(edit_distance("abc", "abcdef"), 3)

    def test_suffix(self):
        self.assertEqual(edit_distance("def", "abcdef"), 3)

    def test_repeated_chars(self):
        self.assertEqual(edit_distance("aaa", "aaaa"), 1)

    def test_longer_strings(self):
        # "intention" -> "execution": 5 edits
        self.assertEqual(edit_distance("intention", "execution"), 5)

    def test_swap_order(self):
        # Should be symmetric
        a, b = "algorithm", "logarithm"
        self.assertEqual(edit_distance(a, b), edit_distance(b, a))


# ---------------------------------------------------------------------------
# edit_distance_threshold
# ---------------------------------------------------------------------------

class TestEditDistanceThreshold(unittest.TestCase):
    """Early-exit Levenshtein with max_dist cutoff."""

    def test_within_threshold(self):
        self.assertEqual(edit_distance_threshold("kitten", "sitting", 3), 3)

    def test_exceeds_threshold(self):
        # kitten->sitting is 3, threshold 2 => returns 3 (max_dist+1)
        self.assertEqual(edit_distance_threshold("kitten", "sitting", 2), 3)

    def test_identical(self):
        self.assertEqual(edit_distance_threshold("abc", "abc", 0), 0)

    def test_empty_within(self):
        self.assertEqual(edit_distance_threshold("", "ab", 2), 2)

    def test_empty_exceeds(self):
        self.assertEqual(edit_distance_threshold("", "abc", 2), 3)

    def test_length_diff_exceeds(self):
        # Length difference 4 > max_dist 2 => early exit
        self.assertEqual(edit_distance_threshold("a", "abcde", 2), 3)

    def test_threshold_zero_identical(self):
        self.assertEqual(edit_distance_threshold("abc", "abc", 0), 0)

    def test_threshold_zero_different(self):
        self.assertEqual(edit_distance_threshold("abc", "abd", 0), 1)

    def test_threshold_one_match(self):
        self.assertEqual(edit_distance_threshold("abc", "abd", 1), 1)

    def test_agrees_with_full(self):
        """Threshold version agrees with the full version when within range."""
        pairs = [
            ("kitten", "sitting"),
            ("abc", "abc"),
            ("", "test"),
            ("intention", "execution"),
            ("saturday", "sunday"),
        ]
        for a, b in pairs:
            full = edit_distance(a, b)
            # When threshold >= full distance, threshold version returns exact
            thresh = edit_distance_threshold(a, b, full)
            self.assertEqual(thresh, full, f"Mismatch for ({a!r}, {b!r})")
            # When threshold < full distance, returns max_dist+1
            if full > 0:
                over = edit_distance_threshold(a, b, full - 1)
                self.assertEqual(over, full, f"Expected sentinel for ({a!r}, {b!r})")

    def test_symmetry(self):
        a, b = "abcdef", "azced"
        t = 5
        self.assertEqual(
            edit_distance_threshold(a, b, t),
            edit_distance_threshold(b, a, t),
        )


# ---------------------------------------------------------------------------
# damerau_levenshtein
# ---------------------------------------------------------------------------

class TestDamerauLevenshtein(unittest.TestCase):
    """Optimal string alignment distance (transpositions count as 1)."""

    def test_identical(self):
        self.assertEqual(damerau_levenshtein("abc", "abc"), 0)

    def test_empty_both(self):
        self.assertEqual(damerau_levenshtein("", ""), 0)

    def test_empty_left(self):
        self.assertEqual(damerau_levenshtein("", "abc"), 3)

    def test_empty_right(self):
        self.assertEqual(damerau_levenshtein("abc", ""), 3)

    def test_single_transposition(self):
        # "ab" -> "ba" is 1 transposition
        self.assertEqual(damerau_levenshtein("ab", "ba"), 1)

    def test_transposition_in_middle(self):
        # "abcd" -> "abdc" is 1 transposition
        self.assertEqual(damerau_levenshtein("abcd", "abdc"), 1)

    def test_levenshtein_vs_damerau(self):
        # "ab" -> "ba": Levenshtein = 2 (sub+sub), Damerau = 1 (transpose)
        self.assertEqual(edit_distance("ab", "ba"), 2)
        self.assertEqual(damerau_levenshtein("ab", "ba"), 1)

    def test_substitution(self):
        self.assertEqual(damerau_levenshtein("abc", "axc"), 1)

    def test_insertion(self):
        self.assertEqual(damerau_levenshtein("abc", "abxc"), 1)

    def test_deletion(self):
        self.assertEqual(damerau_levenshtein("abxc", "abc"), 1)

    def test_mixed_ops(self):
        # "ca" -> "abc": requires multiple operations
        self.assertEqual(damerau_levenshtein("ca", "abc"), 3)

    def test_symmetry(self):
        self.assertEqual(
            damerau_levenshtein("kitten", "sitting"),
            damerau_levenshtein("sitting", "kitten"),
        )


# ---------------------------------------------------------------------------
# DamerauLevenshtein class
# ---------------------------------------------------------------------------

class TestDamerauLevenshteinClass(unittest.TestCase):
    """Convenience class wrapper."""

    def test_distance(self):
        dl = DamerauLevenshtein()
        self.assertEqual(dl.distance("abcd", "abdc"), 1)

    def test_distance_static(self):
        self.assertEqual(DamerauLevenshtein.distance("abc", "abc"), 0)

    def test_normalized_identical(self):
        self.assertAlmostEqual(DamerauLevenshtein.normalized("abc", "abc"), 0.0)

    def test_normalized_completely_different(self):
        self.assertAlmostEqual(DamerauLevenshtein.normalized("abc", "xyz"), 1.0)

    def test_normalized_empty(self):
        self.assertAlmostEqual(DamerauLevenshtein.normalized("", ""), 0.0)

    def test_normalized_range(self):
        val = DamerauLevenshtein.normalized("kitten", "sitting")
        self.assertGreaterEqual(val, 0.0)
        self.assertLessEqual(val, 1.0)

    def test_normalized_one_empty(self):
        self.assertAlmostEqual(DamerauLevenshtein.normalized("abc", ""), 1.0)


# ---------------------------------------------------------------------------
# FuzzyMatcher
# ---------------------------------------------------------------------------

class TestFuzzyMatcherInit(unittest.TestCase):
    """Constructor validation."""

    def test_defaults(self):
        fm = FuzzyMatcher("test")
        self.assertEqual(fm.term, "test")
        self.assertEqual(fm.max_edits, 2)

    def test_custom_max_edits(self):
        fm = FuzzyMatcher("test", max_edits=1)
        self.assertEqual(fm.max_edits, 1)

    def test_negative_max_edits_raises(self):
        with self.assertRaises(ValueError):
            FuzzyMatcher("test", max_edits=-1)

    def test_zero_max_edits(self):
        fm = FuzzyMatcher("test", max_edits=0)
        self.assertTrue(fm.matches("test"))
        self.assertFalse(fm.matches("tast"))


class TestFuzzyMatcherMatches(unittest.TestCase):
    """matches() boolean predicate."""

    def test_exact_match(self):
        fm = FuzzyMatcher("hello", max_edits=1)
        self.assertTrue(fm.matches("hello"))

    def test_one_edit(self):
        fm = FuzzyMatcher("hello", max_edits=1)
        self.assertTrue(fm.matches("hallo"))  # substitution

    def test_two_edits(self):
        fm = FuzzyMatcher("hello", max_edits=2)
        self.assertTrue(fm.matches("hxllo"))  # 1 edit
        self.assertTrue(fm.matches("hxlxo"))  # 2 edits

    def test_exceeds_max_edits(self):
        fm = FuzzyMatcher("hello", max_edits=1)
        self.assertFalse(fm.matches("world"))

    def test_empty_term(self):
        fm = FuzzyMatcher("", max_edits=2)
        self.assertTrue(fm.matches(""))
        self.assertTrue(fm.matches("ab"))
        self.assertFalse(fm.matches("abc"))

    def test_empty_candidate(self):
        fm = FuzzyMatcher("ab", max_edits=2)
        self.assertTrue(fm.matches(""))


class TestFuzzyMatcherDistance(unittest.TestCase):
    """distance() returns exact Levenshtein distance."""

    def test_exact(self):
        fm = FuzzyMatcher("hello")
        self.assertEqual(fm.distance("hello"), 0)

    def test_one_edit(self):
        fm = FuzzyMatcher("hello")
        self.assertEqual(fm.distance("hallo"), 1)

    def test_agrees_with_standalone(self):
        fm = FuzzyMatcher("kitten")
        self.assertEqual(fm.distance("sitting"), edit_distance("kitten", "sitting"))


class TestFuzzyMatcherFindMatches(unittest.TestCase):
    """find_matches() over a vocabulary."""

    def test_basic(self):
        vocab = ["hello", "hallo", "world", "help", "hell"]
        fm = FuzzyMatcher("hello", max_edits=2)
        results = fm.find_matches(vocab)
        terms = [t for t, _ in results]
        self.assertIn("hello", terms)
        self.assertIn("hallo", terms)
        self.assertIn("hell", terms)
        self.assertNotIn("world", terms)

    def test_sorted_by_distance_then_lex(self):
        vocab = ["abc", "axc", "axx", "abc"]
        fm = FuzzyMatcher("abc", max_edits=2)
        results = fm.find_matches(vocab)
        # Check ordering: distance ascending, then lexicographic
        for i in range(len(results) - 1):
            self.assertLessEqual(
                (results[i][1], results[i][0]),
                (results[i + 1][1], results[i + 1][0]),
            )

    def test_empty_vocab(self):
        fm = FuzzyMatcher("hello", max_edits=2)
        self.assertEqual(fm.find_matches([]), [])

    def test_no_matches(self):
        vocab = ["zzzzz", "xxxxx"]
        fm = FuzzyMatcher("hello", max_edits=1)
        self.assertEqual(fm.find_matches(vocab), [])

    def test_exact_only(self):
        vocab = ["abc", "def", "ghi"]
        fm = FuzzyMatcher("abc", max_edits=0)
        results = fm.find_matches(vocab)
        self.assertEqual(results, [("abc", 0)])

    def test_distance_values_correct(self):
        vocab = ["abc", "axc", "axx"]
        fm = FuzzyMatcher("abc", max_edits=2)
        results = fm.find_matches(vocab)
        result_dict = dict(results)
        self.assertEqual(result_dict["abc"], 0)
        self.assertEqual(result_dict["axc"], 1)
        self.assertEqual(result_dict["axx"], 2)


class TestFuzzyMatcherFindMatchesPrefix(unittest.TestCase):
    """find_matches_prefix() with common prefix filter."""

    def test_filters_by_prefix(self):
        vocab = ["hello", "hallo", "world", "help", "hell"]
        fm = FuzzyMatcher("hello", max_edits=2)
        results = fm.find_matches_prefix(vocab, prefix_length=3)
        terms = [t for t, _ in results]
        # "hel" prefix matches "hello", "help", "hell" but not "hallo" or "world"
        self.assertIn("hello", terms)
        self.assertIn("hell", terms)
        self.assertIn("help", terms)
        self.assertNotIn("hallo", terms)
        self.assertNotIn("world", terms)

    def test_prefix_length_longer_than_term(self):
        fm = FuzzyMatcher("hi", max_edits=2)
        vocab = ["hi", "hit", "his", "him"]
        results = fm.find_matches_prefix(vocab, prefix_length=5)
        terms = [t for t, _ in results]
        # effective prefix is "hi" (full term)
        self.assertIn("hi", terms)
        self.assertIn("hit", terms)
        self.assertIn("his", terms)
        self.assertIn("him", terms)

    def test_empty_term_falls_back(self):
        fm = FuzzyMatcher("", max_edits=1)
        vocab = ["a", "b", "ab"]
        results = fm.find_matches_prefix(vocab, prefix_length=3)
        terms = [t for t, _ in results]
        self.assertIn("a", terms)
        self.assertIn("b", terms)
        self.assertNotIn("ab", terms)

    def test_sorted(self):
        vocab = ["hello", "hell", "help"]
        fm = FuzzyMatcher("hello", max_edits=2)
        results = fm.find_matches_prefix(vocab, prefix_length=3)
        for i in range(len(results) - 1):
            self.assertLessEqual(
                (results[i][1], results[i][0]),
                (results[i + 1][1], results[i + 1][0]),
            )

    def test_empty_vocab(self):
        fm = FuzzyMatcher("hello", max_edits=2)
        self.assertEqual(fm.find_matches_prefix([], prefix_length=3), [])

    def test_prefix_default(self):
        vocab = ["hello", "hallo", "help", "world"]
        fm = FuzzyMatcher("hello", max_edits=2)
        # Default prefix_length=3 => prefix "hel"
        results = fm.find_matches_prefix(vocab)
        terms = [t for t, _ in results]
        self.assertIn("hello", terms)
        self.assertIn("help", terms)
        self.assertNotIn("hallo", terms)
        self.assertNotIn("world", terms)


# ---------------------------------------------------------------------------
# Edge cases & integration
# ---------------------------------------------------------------------------

class TestEdgeCases(unittest.TestCase):
    """Edge cases across all functions."""

    def test_unicode(self):
        self.assertEqual(edit_distance("cafe", "caf\u00e9"), 1)

    def test_long_string_threshold(self):
        a = "a" * 100
        b = "a" * 98 + "bb"
        d = edit_distance(a, b)
        t = edit_distance_threshold(a, b, d)
        self.assertEqual(t, d)

    def test_single_char_strings(self):
        self.assertEqual(edit_distance("a", "a"), 0)
        self.assertEqual(edit_distance("a", "b"), 1)
        self.assertEqual(damerau_levenshtein("a", "b"), 1)

    def test_fuzzy_matcher_generator_input(self):
        """find_matches works with generator input."""
        def gen():
            yield "hello"
            yield "hallo"
            yield "world"
        fm = FuzzyMatcher("hello", max_edits=1)
        results = fm.find_matches(gen())
        self.assertEqual(len(results), 2)  # "hello" and "hallo"


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