"""Property-based tests for cdn-ai using Hypothesis.

These tests verify invariants that must hold for ANY input:
- encode() never crashes on any string
- decode() never crashes on any string
- encode() always returns a non-empty string
- decode() always returns a non-empty string
- encode_with_stats() always returns well-formed stats dict
- Encoded output is always shorter or equal in tokens to input (for sufficiently long inputs)
"""

import pytest
from hypothesis import given, settings, assume, HealthCheck
from hypothesis.strategies import (
    text, sampled_from, integers,
    composite,
)

from cdn import encode, decode, encode_with_stats, decode_with_stats


# ── Strategies ────────────────────────────────────────────────────────

# Random unicode text
any_text = text(min_size=0, max_size=500)

# Text more likely to hit encoder paths
action_verbs = sampled_from([
    "search", "filter", "sort", "group", "aggregate", "count", "average",
    "sum", "transform", "merge", "split", "deduplicate", "generate",
    "summarize", "translate", "format", "validate", "compare", "classify",
    "extract", "enrich", "rank", "call", "read", "write", "delete",
    "execute", "run", "wait", "retry", "log", "review", "create",
    "deploy", "test", "build", "scan", "monitor", "notify", "approve",
    "schedule", "backup", "wire", "assemble",
])

msg_type_prefixes = sampled_from([
    "!:", "!?:", "!!:", "?:", "=:", ">:", "<:", "~:", "#:", "X:", "E:", "@:",
])

urgency_prefixes = sampled_from(["", "+++", "++", "+", "-", "--"])

modifiers = sampled_from([
    "/n:5", "/fmt:json", "/fmt:bullets", "/fmt:md", "/lang:es",
    "/desc", "/asc", "/timeout:30s", "/each", "/scan", "/repeat:3",
    "/since:7d",
])

fallbacks = sampled_from([
    "fb:cache", "fb:skip", "fb:abort", "fb:degrade", "fb:ask", "fb:default",
])

confidence_markers = sampled_from(["!", "!!", "~", "~~", "???"])


@composite
def condensa_messages(draw):
    """Generate syntactically plausible Condensa messages."""
    urgency = draw(urgency_prefixes)
    prefix = draw(msg_type_prefixes)
    verb = draw(sampled_from(["srch", "filt", "sort", "grp", "sumz", "read", "wrt", "exec", "xlat", "gen", "fmt"]))
    arg = draw(sampled_from(["test", "'hello world'", "data", "$_", "users", "region"]))
    mod = draw(sampled_from(["", draw(modifiers)]))
    fb = draw(sampled_from(["", draw(fallbacks)]))
    parts = [f"{urgency}{prefix}{verb}", arg]
    if mod:
        parts.append(mod)
    if fb:
        parts.append(fb)
    return " ".join(parts)


@composite
def nl_commands(draw):
    """Generate natural language command strings."""
    verb = draw(action_verbs)
    obj = draw(sampled_from([
        "the data", "users", "news", "records", "the file", "SpaceX",
        "active accounts", "results", "the report", "CPU usage",
    ]))
    suffix = draw(sampled_from([
        "", " and return top 5", " in json format", " descending",
        " from the database", " by region", " where status is active",
        " synchronously", " for each item",
    ]))
    return f"{verb} {obj}{suffix}"


@composite
def pipeline_messages(draw):
    """Generate Condensa pipeline messages."""
    n_stages = draw(integers(min_value=2, max_value=5))
    stages = []
    for i in range(n_stages):
        verb = draw(sampled_from(["srch", "filt", "sort", "grp", "sumz", "avg", "fmt"]))
        arg = draw(sampled_from(["data", "active", "region", "revenue", "'test'"]))
        mod = draw(sampled_from(["", "/n:5", "/desc", "/fmt:json"]))
        if i == 0:
            stage = f"!:{verb} {arg}"
        else:
            stage = f"{verb} {arg}"
        if mod:
            stage += f" {mod}"
        stages.append(stage)
    return " | ".join(stages)


# ── Core invariant tests ──────────────────────────────────────────────

class TestEncodeNeverCrashes:
    """encode() must never raise an exception, regardless of input."""

    @given(any_text)
    @settings(max_examples=200, suppress_health_check=[HealthCheck.too_slow])
    def test_encode_arbitrary_text(self, text):
        result = encode(text)
        assert isinstance(result, str)

    @given(nl_commands())
    @settings(max_examples=100)
    def test_encode_nl_commands(self, text):
        result = encode(text)
        assert isinstance(result, str)
        assert len(result) > 0

    @given(condensa_messages())
    @settings(max_examples=100)
    def test_encode_condensa_input(self, text):
        """Encoding already-encoded Condensa should not crash."""
        result = encode(text)
        assert isinstance(result, str)


class TestDecodeNeverCrashes:
    """decode() must never raise an exception, regardless of input."""

    @given(any_text)
    @settings(max_examples=200, suppress_health_check=[HealthCheck.too_slow])
    def test_decode_arbitrary_text(self, text):
        result = decode(text)
        assert isinstance(result, str)

    @given(condensa_messages())
    @settings(max_examples=100)
    def test_decode_condensa_messages(self, text):
        result = decode(text)
        assert isinstance(result, str)
        assert len(result) > 0

    @given(pipeline_messages())
    @settings(max_examples=50)
    def test_decode_pipelines(self, text):
        result = decode(text)
        assert isinstance(result, str)
        assert len(result) > 0


class TestEncodeDecodeProperties:
    """Properties that must hold across encode/decode."""

    @given(any_text)
    @settings(max_examples=100, suppress_health_check=[HealthCheck.too_slow])
    def test_encode_returns_nonempty_for_nonempty(self, text):
        assume(len(text.strip()) > 0)
        result = encode(text)
        assert len(result) > 0, f"encode('{text[:50]}...') returned empty string"

    @given(nl_commands())
    @settings(max_examples=100)
    def test_encode_starts_with_valid_prefix(self, text):
        result = encode(text)
        valid_prefixes = ("!", "?", "=", ">", "<", "~", "#", "X", "E", "@",
                          "+", "-", "seq(", "par(", "if(")
        assert any(result.startswith(p) for p in valid_prefixes), \
            f"'{result}' does not start with a valid Condensa prefix"

    @given(nl_commands())
    @settings(max_examples=50)
    def test_encode_decode_roundtrip_no_crash(self, text):
        encoded = encode(text)
        decoded = decode(encoded)
        assert isinstance(decoded, str)
        assert len(decoded) > 0

    @given(condensa_messages())
    @settings(max_examples=50)
    def test_decode_encode_no_crash(self, text):
        decoded = decode(text)
        re_encoded = encode(decoded)
        assert isinstance(re_encoded, str)


class TestStatsProperties:
    """encode_with_stats / decode_with_stats invariants."""

    @given(nl_commands())
    @settings(max_examples=50)
    def test_encode_stats_keys(self, text):
        stats = encode_with_stats(text)
        assert isinstance(stats, dict)
        required_keys = {"original", "condensa", "nl_tokens", "condensa_tokens", "reduction_pct"}
        assert required_keys.issubset(stats.keys()), \
            f"Missing keys: {required_keys - stats.keys()}"

    @given(nl_commands())
    @settings(max_examples=50)
    def test_encode_stats_token_counts_non_negative(self, text):
        stats = encode_with_stats(text)
        assert stats["nl_tokens"] >= 0
        assert stats["condensa_tokens"] >= 0

    @given(condensa_messages())
    @settings(max_examples=50)
    def test_decode_stats_keys(self, text):
        stats = decode_with_stats(text)
        assert isinstance(stats, dict)
        required_keys = {"condensa", "natural_language", "condensa_tokens", "nl_tokens", "expansion_ratio"}
        assert required_keys.issubset(stats.keys()), \
            f"Missing keys: {required_keys - stats.keys()}"


class TestIdempotency:
    """Decoding should be stable — decoding twice should not change output."""

    @given(condensa_messages())
    @settings(max_examples=50)
    def test_decode_idempotent_on_decoded_output(self, text):
        """decode(x) produces NL. decode(decode(x)) should be stable
        (the second decode just wraps it as unparseable or passes through)."""
        first = decode(text)
        second = decode(first)
        # The key property: second decode should not crash and should be deterministic
        third = decode(first)
        assert second == third, "decode() is not deterministic"

    @given(nl_commands())
    @settings(max_examples=50)
    def test_encode_deterministic(self, text):
        """Same input always produces same output."""
        a = encode(text)
        b = encode(text)
        assert a == b, f"encode() not deterministic: '{a}' != '{b}'"


class TestAdversarialInputs:
    """Test inputs designed to break parsers."""

    @pytest.mark.parametrize("evil_input", [
        # Injection-style
        "!:; rm -rf /",
        "!:srch 'test'; DROP TABLE users;--",
        "!:exec $(whoami)",
        "!:read /etc/passwd",
        # Deeply nested
        "seq(" * 50 + "!:srch test" + ")" * 50,
        "[[[[[[!:srch test]]]]]]",
        # Unbalanced
        "!:srch 'unterminated",
        "!:srch <<<no close",
        "!:wrt <<<{\"key\": \"value\">>>",
        "seq(!:a; par(!:b",
        # Null / special bytes
        "!:srch \x00\x01\x02",
        "!:srch \n\r\t",
        # Empty / whitespace
        "",
        " ",
        "\t\n\r",
        "   !:   ",
        # Huge repetition
        "!" * 1000,
        ":" * 1000,
        "/ " * 500,
        # Unicode edge cases
        "!:srch \U0001f600\U0001f600\U0001f600",  # emoji
        "!:srch \u200b\u200b\u200b",  # zero-width spaces
        "!:srch \u202e\u202ereversed",  # RTL override
        # Regex-busting patterns
        "a" * 500 + "!" + "a" * 500,
        "((((((((((a))))))))))",
        # Type confusion
        "!:123",
        "E:E:E:E:",
        "++++++!:srch",
        "!!!!!:srch",
    ])
    def test_adversarial_encode(self, evil_input):
        result = encode(evil_input)
        assert isinstance(result, str)

    @pytest.mark.parametrize("evil_input", [
        "!:; rm -rf /",
        "!:srch 'test'; DROP TABLE users;--",
        "seq(" * 50 + "!:srch test" + ")" * 50,
        "!:srch 'unterminated",
        "!:srch <<<no close",
        "",
        " ",
        "!" * 1000,
        "!:srch \x00\x01\x02",
        "!:srch \U0001f600\U0001f600\U0001f600",
        "E:E:E:E:",
        "++++++!:srch",
    ])
    def test_adversarial_decode(self, evil_input):
        result = decode(evil_input)
        assert isinstance(result, str)
