"""
tests/test_validation.py — Tests for cortex/validation.py

Covers:
  - validate_str: type checks, whitespace stripping, control char removal,
    empty rejection, length enforcement
  - validate_optional_str: None pass-through
  - validate_claim_fields: per-field limits and sanitization
  - validate_retrieve_query: 2K limit
  - validate_cornerstone_fields: label 200, content 5K
  - validate_conversation: list/str, per-message limit, total limit, null bytes

Also includes storage-level integration tests (create_claim, seal_cornerstone)
and plugin-level integration tests (remember, retrieve, seal_cornerstone).
"""
from __future__ import annotations

import pytest
import pytest_asyncio

from cortex.validation import (
    MAX_CLAIM_FIELD_LEN,
    MAX_CONVERSATION_LEN,
    MAX_CORNERSTONE_CONTENT_LEN,
    MAX_CORNERSTONE_LABEL_LEN,
    MAX_MESSAGE_LEN,
    MAX_QUERY_LEN,
    validate_claim_fields,
    validate_conversation,
    validate_cornerstone_fields,
    validate_optional_str,
    validate_retrieve_query,
    validate_str,
)


# ---------------------------------------------------------------------------
# validate_str
# ---------------------------------------------------------------------------

class TestValidateStr:
    def test_returns_stripped_string(self):
        assert validate_str("  hello  ", "f", max_len=100) == "hello"

    def test_strips_null_bytes(self):
        result = validate_str("foo\x00bar", "f", max_len=100)
        assert "\x00" not in result
        assert "foo" in result
        assert "bar" in result

    def test_strips_other_control_chars(self):
        result = validate_str("foo\x01\x1fbar", "f", max_len=100)
        assert "\x01" not in result
        assert "\x1f" not in result

    def test_preserves_newline_and_tab(self):
        # Tab in the middle is preserved; trailing whitespace is stripped
        result = validate_str("foo\nbar\tbaz", "f", max_len=100)
        assert "\n" in result
        assert "\t" in result

    def test_raises_on_empty_after_strip(self):
        with pytest.raises(ValueError, match="must not be empty"):
            validate_str("   ", "myfield", max_len=100)

    def test_allow_empty(self):
        result = validate_str("   ", "myfield", max_len=100, allow_empty=True)
        assert result == ""

    def test_raises_on_exceeding_max_len(self):
        with pytest.raises(ValueError, match="exceeds maximum length"):
            validate_str("x" * 101, "myfield", max_len=100)

    def test_passes_at_exact_max_len(self):
        result = validate_str("x" * 100, "myfield", max_len=100)
        assert len(result) == 100

    def test_raises_type_error_for_non_str(self):
        with pytest.raises(TypeError, match="must be a str"):
            validate_str(123, "myfield", max_len=100)

    def test_raises_type_error_for_none(self):
        with pytest.raises(TypeError, match="must be a str"):
            validate_str(None, "myfield", max_len=100)

    def test_error_message_includes_field_name(self):
        with pytest.raises(ValueError, match="'myspecialfield'"):
            validate_str("", "myspecialfield", max_len=100)


# ---------------------------------------------------------------------------
# validate_optional_str
# ---------------------------------------------------------------------------

class TestValidateOptionalStr:
    def test_none_passthrough(self):
        assert validate_optional_str(None, "f", max_len=100) is None

    def test_sanitizes_non_none(self):
        result = validate_optional_str("  hello\x00  ", "f", max_len=100)
        assert result == "hello"

    def test_enforces_max_len(self):
        with pytest.raises(ValueError, match="exceeds maximum length"):
            validate_optional_str("x" * 101, "f", max_len=100)


# ---------------------------------------------------------------------------
# validate_claim_fields
# ---------------------------------------------------------------------------

class TestValidateClaimFields:
    def test_happy_path(self):
        s, p, o = validate_claim_fields("Alice", "likes", "coffee")
        assert s == "Alice"
        assert p == "likes"
        assert o == "coffee"

    def test_strips_whitespace_from_all_three(self):
        s, p, o = validate_claim_fields("  A  ", "  B  ", "  C  ")
        assert s == "A"
        assert p == "B"
        assert o == "C"

    def test_strips_null_bytes(self):
        s, p, o = validate_claim_fields("A\x00", "B\x00", "C\x00")
        assert "\x00" not in s
        assert "\x00" not in p
        assert "\x00" not in o

    def test_subject_too_long(self):
        with pytest.raises(ValueError, match="'subject'.*exceeds maximum"):
            validate_claim_fields("x" * (MAX_CLAIM_FIELD_LEN + 1), "pred", "obj")

    def test_predicate_too_long(self):
        with pytest.raises(ValueError, match="'predicate'.*exceeds maximum"):
            validate_claim_fields("subj", "x" * (MAX_CLAIM_FIELD_LEN + 1), "obj")

    def test_object_value_too_long(self):
        with pytest.raises(ValueError, match="'object_value'.*exceeds maximum"):
            validate_claim_fields("subj", "pred", "x" * (MAX_CLAIM_FIELD_LEN + 1))

    def test_empty_subject_rejected(self):
        with pytest.raises(ValueError, match="'subject'"):
            validate_claim_fields("", "pred", "obj")

    def test_at_max_len_passes(self):
        s, p, o = validate_claim_fields(
            "s" * MAX_CLAIM_FIELD_LEN,
            "p" * MAX_CLAIM_FIELD_LEN,
            "o" * MAX_CLAIM_FIELD_LEN,
        )
        assert len(s) == MAX_CLAIM_FIELD_LEN


# ---------------------------------------------------------------------------
# validate_retrieve_query
# ---------------------------------------------------------------------------

class TestValidateRetrieveQuery:
    def test_normal_query(self):
        result = validate_retrieve_query("  what does the user like?  ")
        assert result == "what does the user like?"

    def test_null_bytes_stripped(self):
        result = validate_retrieve_query("query\x00data")
        assert "\x00" not in result

    def test_too_long(self):
        with pytest.raises(ValueError, match="'query'.*exceeds maximum"):
            validate_retrieve_query("x" * (MAX_QUERY_LEN + 1))

    def test_at_max_len_passes(self):
        result = validate_retrieve_query("q" * MAX_QUERY_LEN)
        assert len(result) == MAX_QUERY_LEN

    def test_empty_rejected(self):
        with pytest.raises(ValueError, match="must not be empty"):
            validate_retrieve_query("   ")


# ---------------------------------------------------------------------------
# validate_cornerstone_fields
# ---------------------------------------------------------------------------

class TestValidateCornerstoneFields:
    def test_happy_path(self):
        lbl, content = validate_cornerstone_fields("My Label", "some content")
        assert lbl == "My Label"
        assert content == "some content"

    def test_strips_whitespace(self):
        lbl, content = validate_cornerstone_fields("  L  ", "  C  ")
        assert lbl == "L"
        assert content == "C"

    def test_label_too_long(self):
        with pytest.raises(ValueError, match="'label'.*exceeds maximum"):
            validate_cornerstone_fields("x" * (MAX_CORNERSTONE_LABEL_LEN + 1), "content")

    def test_content_too_long(self):
        with pytest.raises(ValueError, match="'content'.*exceeds maximum"):
            validate_cornerstone_fields("label", "x" * (MAX_CORNERSTONE_CONTENT_LEN + 1))

    def test_at_limits_passes(self):
        lbl, content = validate_cornerstone_fields(
            "L" * MAX_CORNERSTONE_LABEL_LEN,
            "C" * MAX_CORNERSTONE_CONTENT_LEN,
        )
        assert len(lbl) == MAX_CORNERSTONE_LABEL_LEN
        assert len(content) == MAX_CORNERSTONE_CONTENT_LEN

    def test_null_bytes_stripped(self):
        lbl, content = validate_cornerstone_fields("la\x00bel", "con\x00tent")
        assert "\x00" not in lbl
        assert "\x00" not in content


# ---------------------------------------------------------------------------
# validate_conversation
# ---------------------------------------------------------------------------

class TestValidateConversation:
    def test_plain_string_wrapped(self):
        result = validate_conversation("Hello world")
        assert isinstance(result, list)
        assert result[0]["content"] == "Hello world"

    def test_list_of_message_dicts(self):
        msgs = [{"role": "user", "content": "hi"}, {"role": "assistant", "content": "hey"}]
        result = validate_conversation(msgs)
        assert len(result) == 2

    def test_strips_whitespace_from_content(self):
        msgs = [{"role": "user", "content": "  hello  "}]
        result = validate_conversation(msgs)
        assert result[0]["content"] == "hello"

    def test_strips_null_bytes_from_content(self):
        msgs = [{"role": "user", "content": "hello\x00world"}]
        result = validate_conversation(msgs)
        assert "\x00" not in result[0]["content"]

    def test_individual_message_too_long(self):
        msgs = [{"role": "user", "content": "x" * (MAX_MESSAGE_LEN + 1)}]
        with pytest.raises(ValueError, match="exceeds maximum length"):
            validate_conversation(msgs)

    def test_total_conversation_too_long(self):
        # Each message is fine, but total exceeds 100K
        chunk = "x" * (MAX_MESSAGE_LEN)  # 10K each
        msgs = [{"role": "user", "content": chunk} for _ in range(11)]  # 110K total
        with pytest.raises(ValueError, match="Conversation total length exceeds"):
            validate_conversation(msgs)

    def test_empty_list_rejected(self):
        with pytest.raises(ValueError, match="must not be empty"):
            validate_conversation([])

    def test_non_list_non_str_rejected(self):
        with pytest.raises(TypeError, match="must be a list"):
            validate_conversation({"role": "user", "content": "hi"})

    def test_list_of_strings_accepted(self):
        result = validate_conversation(["user: hello", "assistant: hi"])
        assert len(result) == 2
        assert result[0]["content"] == "user: hello"

    def test_non_str_non_dict_element_rejected(self):
        with pytest.raises(TypeError, match="must be a dict or str"):
            validate_conversation([123])

    def test_plain_string_too_long(self):
        with pytest.raises(ValueError, match="exceeds maximum length"):
            validate_conversation("x" * (MAX_CONVERSATION_LEN + 1))

    def test_preserves_role_field(self):
        msgs = [{"role": "assistant", "content": "hello"}]
        result = validate_conversation(msgs)
        assert result[0]["role"] == "assistant"


# ---------------------------------------------------------------------------
# Storage-level integration tests (create_claim, seal_cornerstone)
# ---------------------------------------------------------------------------

@pytest_asyncio.fixture
async def adapter():
    from cortex.storage.sqlite_adapter import SQLiteAdapter
    a = SQLiteAdapter(db_path=":memory:", busy_timeout_ms=1000, enable_wal=False)
    await a.initialize()
    yield a
    await a.close()


class TestStorageCreateClaimValidation:
    async def test_valid_claim_is_stored(self, adapter):
        claim = await adapter.create_claim("Alice", "likes", "coffee")
        assert claim.subject == "Alice"
        assert claim.predicate == "likes"
        assert claim.object_value == "coffee"

    async def test_strips_whitespace(self, adapter):
        claim = await adapter.create_claim("  Alice  ", "  likes  ", "  coffee  ")
        assert claim.subject == "Alice"

    async def test_rejects_null_byte_in_subject(self, adapter):
        # Null bytes are stripped, not rejected — resulting cleaned value stored
        claim = await adapter.create_claim("Al\x00ice", "likes", "coffee")
        assert "\x00" not in claim.subject

    async def test_rejects_oversized_subject(self, adapter):
        with pytest.raises(ValueError, match="'subject'.*exceeds maximum"):
            await adapter.create_claim("x" * (MAX_CLAIM_FIELD_LEN + 1), "pred", "obj")

    async def test_rejects_oversized_predicate(self, adapter):
        with pytest.raises(ValueError, match="'predicate'.*exceeds maximum"):
            await adapter.create_claim("subj", "x" * (MAX_CLAIM_FIELD_LEN + 1), "obj")

    async def test_rejects_oversized_object_value(self, adapter):
        with pytest.raises(ValueError, match="'object_value'.*exceeds maximum"):
            await adapter.create_claim("subj", "pred", "x" * (MAX_CLAIM_FIELD_LEN + 1))

    async def test_rejects_empty_subject(self, adapter):
        with pytest.raises(ValueError, match="'subject'"):
            await adapter.create_claim("", "pred", "obj")


class TestStorageSealCornerstoneValidation:
    async def test_valid_cornerstone_is_stored(self, adapter):
        cs = await adapter.seal_cornerstone("My Identity", "I am Eva.")
        assert cs.label == "My Identity"
        assert cs.content == "I am Eva."

    async def test_strips_whitespace(self, adapter):
        cs = await adapter.seal_cornerstone("  Label  ", "  Content  ")
        assert cs.label == "Label"
        assert cs.content == "Content"

    async def test_rejects_oversized_label(self, adapter):
        with pytest.raises(ValueError, match="'label'.*exceeds maximum"):
            await adapter.seal_cornerstone("x" * (MAX_CORNERSTONE_LABEL_LEN + 1), "content")

    async def test_rejects_oversized_content(self, adapter):
        with pytest.raises(ValueError, match="'content'.*exceeds maximum"):
            await adapter.seal_cornerstone("label", "x" * (MAX_CORNERSTONE_CONTENT_LEN + 1))

    async def test_null_bytes_stripped_from_content(self, adapter):
        cs = await adapter.seal_cornerstone("label", "con\x00tent")
        assert "\x00" not in cs.content
