"""Tests for rbsp.indexer.inverted -- InvertedIndex, tokenizer, and query parser."""

import os
import sys
import threading
import unittest

sys.path.insert(0, os.path.dirname(__file__))
from conftest import RBSPTestCase, FIXED_TIMESTAMP, SAMPLE_TEXTS

from rbsp.indexer.inverted import (
    InvertedIndex,
    tokenize,
    tokenize_unique,
    parse_query,
    TermNode,
    AndNode,
    OrNode,
    NotNode,
)


# ---------------------------------------------------------------------------
# Tokenizer tests
# ---------------------------------------------------------------------------

class TestTokenize(unittest.TestCase):
    """Tests for the tokenize() function."""

    def test_simple_sentence(self):
        tokens = tokenize("hello world")
        self.assertEqual(tokens, ["hello", "world"])

    def test_camel_case_splitting(self):
        tokens = tokenize("camelCaseIdentifier")
        self.assertIn("camel", tokens)
        self.assertIn("case", tokens)
        self.assertIn("identifier", tokens)

    def test_pascal_case_splitting(self):
        tokens = tokenize("PascalCaseName")
        self.assertIn("pascal", tokens)
        self.assertIn("case", tokens)
        self.assertIn("name", tokens)

    def test_stop_word_removal(self):
        tokens = tokenize("the quick brown fox is a dog")
        self.assertNotIn("the", tokens)
        self.assertNotIn("is", tokens)
        self.assertNotIn("a", tokens)
        self.assertIn("quick", tokens)
        self.assertIn("brown", tokens)
        self.assertIn("fox", tokens)
        self.assertIn("dog", tokens)

    def test_short_token_filtering(self):
        tokens = tokenize("I a go to x")
        # "I", "a", "x" are < 2 chars, "to" is a stop word
        # "go" has 2 chars and is not a stop word
        self.assertNotIn("x", tokens)
        self.assertIn("go", tokens)

    def test_lowercasing(self):
        tokens = tokenize("HELLO World")
        self.assertIn("hello", tokens)
        self.assertIn("world", tokens)

    def test_non_alphanumeric_splitting(self):
        tokens = tokenize("hello_world foo-bar baz.qux")
        self.assertIn("hello", tokens)
        self.assertIn("world", tokens)
        self.assertIn("foo", tokens)
        self.assertIn("bar", tokens)
        self.assertIn("baz", tokens)
        self.assertIn("qux", tokens)

    def test_empty_string(self):
        tokens = tokenize("")
        self.assertEqual(tokens, [])

    def test_only_stop_words(self):
        tokens = tokenize("the a an and or not")
        self.assertEqual(tokens, [])

    def test_preserves_duplicates(self):
        tokens = tokenize("hello hello hello")
        self.assertEqual(tokens.count("hello"), 3)

    def test_numeric_tokens(self):
        tokens = tokenize("foo 42 bar")
        self.assertIn("foo", tokens)
        self.assertIn("42", tokens)
        self.assertIn("bar", tokens)


class TestTokenizeUnique(unittest.TestCase):
    """Tests for tokenize_unique()."""

    def test_deduplication(self):
        result = tokenize_unique("hello hello world world")
        self.assertIsInstance(result, set)
        self.assertEqual(result, {"hello", "world"})

    def test_empty_string(self):
        result = tokenize_unique("")
        self.assertEqual(result, set())

    def test_returns_set_type(self):
        result = tokenize_unique("foo bar baz")
        self.assertIsInstance(result, set)


# ---------------------------------------------------------------------------
# Query parser tests
# ---------------------------------------------------------------------------

class TestParseQuery(unittest.TestCase):
    """Tests for parse_query()."""

    def test_single_term(self):
        node = parse_query("python")
        self.assertIsInstance(node, TermNode)
        self.assertEqual(node.term, "python")

    def test_and_query(self):
        node = parse_query("python AND hash")
        self.assertIsInstance(node, AndNode)
        self.assertEqual(len(node.children), 2)

    def test_or_query(self):
        node = parse_query("python OR java")
        self.assertIsInstance(node, OrNode)
        self.assertEqual(len(node.children), 2)

    def test_not_query(self):
        node = parse_query("NOT python")
        self.assertIsInstance(node, NotNode)
        self.assertIsInstance(node.child, TermNode)
        self.assertEqual(node.child.term, "python")

    def test_implicit_and(self):
        # Multiple terms without operators -> implicit AND
        node = parse_query("alpha beta gamma")
        self.assertIsInstance(node, AndNode)
        self.assertEqual(len(node.children), 3)

    def test_empty_query(self):
        node = parse_query("")
        self.assertIsInstance(node, TermNode)
        self.assertEqual(node.term, "")

    def test_case_insensitive_operators(self):
        node_lower = parse_query("foo and bar")
        self.assertIsInstance(node_lower, AndNode)
        node_upper = parse_query("foo AND bar")
        self.assertIsInstance(node_upper, AndNode)

    def test_and_binds_tighter_than_or(self):
        # "a AND b OR c" -> OR(AND(a, b), c)
        node = parse_query("alpha AND beta OR gamma")
        self.assertIsInstance(node, OrNode)

    def test_not_at_end(self):
        # "NOT" at end with no following term
        node = parse_query("NOT")
        self.assertIsInstance(node, TermNode)
        self.assertEqual(node.term, "")


# ---------------------------------------------------------------------------
# InvertedIndex tests
# ---------------------------------------------------------------------------

class TestInvertedIndexBasics(unittest.TestCase):
    """Core add/lookup/remove semantics."""

    def setUp(self):
        self.idx = InvertedIndex()

    def test_empty_index(self):
        self.assertEqual(len(self.idx), 0)
        self.assertEqual(self.idx.doc_count(), 0)
        self.assertEqual(self.idx.term_count(), 0)
        self.assertEqual(self.idx.all_doc_ids(), set())

    def test_add_document_and_lookup(self):
        self.idx.add_document("doc1", ["python", "hash", "index"])
        self.assertIn("doc1", self.idx.lookup("python"))
        self.assertIn("doc1", self.idx.lookup("hash"))
        self.assertIn("doc1", self.idx.lookup("index"))

    def test_lookup_missing_term(self):
        self.idx.add_document("doc1", ["python"])
        result = self.idx.lookup("java")
        self.assertEqual(result, set())

    def test_add_text(self):
        self.idx.add_text("doc1", "Python programming language")
        # "python" and "programming" should be indexed (after lowercasing)
        # "language" should be indexed too
        self.assertIn("doc1", self.idx.lookup("python"))
        self.assertIn("doc1", self.idx.lookup("programming"))

    def test_remove_document(self):
        self.idx.add_document("doc1", ["python", "hash"])
        self.assertTrue(self.idx.remove_document("doc1"))
        self.assertEqual(self.idx.lookup("python"), set())
        self.assertEqual(self.idx.lookup("hash"), set())
        self.assertEqual(self.idx.doc_count(), 0)

    def test_remove_nonexistent_document(self):
        self.assertFalse(self.idx.remove_document("nope"))

    def test_remove_cleans_up_terms(self):
        self.idx.add_document("doc1", ["unique_term"])
        self.idx.remove_document("doc1")
        self.assertEqual(self.idx.term_count(), 0)

    def test_all_doc_ids(self):
        self.idx.add_document("a", ["x"])
        self.idx.add_document("b", ["y"])
        self.idx.add_document("c", ["z"])
        self.assertEqual(self.idx.all_doc_ids(), {"a", "b", "c"})

    def test_doc_count(self):
        self.idx.add_document("a", ["x"])
        self.idx.add_document("b", ["y"])
        self.assertEqual(self.idx.doc_count(), 2)

    def test_term_count(self):
        self.idx.add_document("doc1", ["alpha", "beta", "gamma"])
        self.assertEqual(self.idx.term_count(), 3)

    def test_term_frequency(self):
        self.idx.add_document("d1", ["python", "code"])
        self.idx.add_document("d2", ["python", "hash"])
        self.idx.add_document("d3", ["java", "code"])
        self.assertEqual(self.idx.term_frequency("python"), 2)
        self.assertEqual(self.idx.term_frequency("code"), 2)
        self.assertEqual(self.idx.term_frequency("hash"), 1)
        self.assertEqual(self.idx.term_frequency("nonexistent"), 0)

    def test_contains(self):
        self.idx.add_document("doc1", ["x"])
        self.assertIn("doc1", self.idx)
        self.assertNotIn("doc2", self.idx)

    def test_len(self):
        self.idx.add_document("a", ["x"])
        self.idx.add_document("b", ["y"])
        self.assertEqual(len(self.idx), 2)

    def test_clear(self):
        self.idx.add_document("d1", ["a", "b"])
        self.idx.add_document("d2", ["c", "d"])
        self.idx.clear()
        self.assertEqual(len(self.idx), 0)
        self.assertEqual(self.idx.term_count(), 0)
        self.assertEqual(self.idx.all_doc_ids(), set())


class TestInvertedIndexReindex(unittest.TestCase):
    """Re-indexing a document replaces its tokens."""

    def setUp(self):
        self.idx = InvertedIndex()

    def test_reindex_removes_old_tokens(self):
        self.idx.add_document("doc1", ["old_token"])
        self.idx.add_document("doc1", ["new_token"])
        self.assertEqual(self.idx.lookup("old_token"), set())
        self.assertIn("doc1", self.idx.lookup("new_token"))

    def test_reindex_preserves_doc_count(self):
        self.idx.add_document("doc1", ["a"])
        self.idx.add_document("doc1", ["b"])
        self.assertEqual(self.idx.doc_count(), 1)


class TestInvertedIndexSearch(unittest.TestCase):
    """Boolean search query evaluation."""

    def setUp(self):
        self.idx = InvertedIndex()
        self.idx.add_document("d1", ["python", "hash", "fast"])
        self.idx.add_document("d2", ["python", "index", "fast"])
        self.idx.add_document("d3", ["java", "hash", "slow"])

    def test_single_term_search(self):
        result = self.idx.search("python")
        self.assertEqual(result, {"d1", "d2"})

    def test_and_search(self):
        result = self.idx.search("python AND hash")
        self.assertEqual(result, {"d1"})

    def test_or_search(self):
        result = self.idx.search("python OR java")
        self.assertEqual(result, {"d1", "d2", "d3"})

    def test_not_search(self):
        result = self.idx.search("NOT python")
        self.assertEqual(result, {"d3"})

    def test_and_no_match(self):
        result = self.idx.search("python AND java")
        self.assertEqual(result, set())

    def test_lookup_returns_copy(self):
        result = self.idx.lookup("python")
        result.add("injected")
        # Original should not be affected
        self.assertNotIn("injected", self.idx.lookup("python"))


class TestInvertedIndexThreadSafety(unittest.TestCase):
    """Concurrent add and lookup operations."""

    def test_concurrent_adds(self):
        idx = InvertedIndex()
        errors = []

        def add_docs(start, count):
            try:
                for i in range(start, start + count):
                    idx.add_document(f"doc_{i}", [f"term_{i}", "shared"])
            except Exception as e:
                errors.append(e)

        threads = [
            threading.Thread(target=add_docs, args=(i * 100, 100))
            for i in range(4)
        ]
        for t in threads:
            t.start()
        for t in threads:
            t.join()

        self.assertEqual(errors, [])
        self.assertEqual(idx.doc_count(), 400)

    def test_concurrent_add_and_lookup(self):
        idx = InvertedIndex()
        idx.add_document("seed", ["target"])
        errors = []

        def writer():
            try:
                for i in range(200):
                    idx.add_document(f"w_{i}", ["target", f"extra_{i}"])
            except Exception as e:
                errors.append(e)

        def reader():
            try:
                for _ in range(200):
                    result = idx.lookup("target")
                    # Should always contain at least "seed"
                    self.assertIn("seed", result)
            except Exception as e:
                errors.append(e)

        t_write = threading.Thread(target=writer)
        t_read = threading.Thread(target=reader)
        t_write.start()
        t_read.start()
        t_write.join()
        t_read.join()

        self.assertEqual(errors, [])


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