#!/usr/bin/env python3
"""
RAG System Test Suite
=====================
Tests all core RAG modules: embeddings, ingestion, search, vector store,
entity extraction, temporal decay, contextual chunking, hallucination guard.

NOTE: This suite uses a custom @test() decorator + run_tests() runner.
Do NOT run with pytest - it will produce false failures (pytest-asyncio
auto-collects these functions but they aren't standard pytest test items).

Correct runner:
    docker exec rmi-backend python tests/test_rag.py

A pytest.ini in /root/backend/ disables auto-collection to prevent
accidental pytest runs from reporting failures.
"""

import asyncio
import os
import sys
from datetime import UTC

# Ensure app/ is importable
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))


# ══════════════════════════════════════════════════════════════════════
# Lightweight test runner (no pytest dependency needed)
# ══════════════════════════════════════════════════════════════════════

_results = []
_current = None


def test(name):
    """Decorator to mark a test function."""

    def decorator(fn):
        if asyncio.iscoroutinefunction(fn):
            _results.append((name, fn, True))
        else:
            _results.append((name, fn, False))
        return fn

    return decorator


async def run_tests():
    """Run all registered tests and print results."""
    passed = 0
    failed = 0
    errors = []

    for name, fn, is_async in _results:
        try:
            if is_async:
                await fn()
            else:
                fn()
            passed += 1
            print(f"  PASS  {name}")
        except Exception as e:
            failed += 1
            errors.append((name, str(e)))
            print(f"  FAIL  {name}: {e}")

    print(f"\n{'=' * 60}")
    print(f"  Results: {passed} passed, {failed} failed, {passed + failed} total")
    if errors:
        print("\n  Failures:")
        for name, err in errors:
            print(f"    - {name}: {err[:80]}")
    print(f"{'=' * 60}")
    return failed == 0


# ══════════════════════════════════════════════════════════════════════
# FEATURE EXTRACTORS (pure functions, no API/model needed)
# ══════════════════════════════════════════════════════════════════════


@test("extract_contract_features: returns 128-dim vector")
def test_contract_features_dims():
    from app.crypto_embeddings import extract_contract_features

    vec = extract_contract_features("pragma solidity; function mint() external onlyOwner {}")
    assert len(vec) == 128, f"Expected 128 dims, got {len(vec)}"
    assert vec.dtype.name.startswith("float32"), f"Expected float32, got {vec.dtype}"


@test("extract_contract_features: detects rug patterns")
def test_contract_features_rug_detection():
    from app.crypto_embeddings import extract_contract_features

    rug_code = "function mint(address to, uint256 amount) external onlyOwner { maxTxAmount = 0; }"
    vec = extract_contract_features(rug_code)
    # The keyword list checks lowercased code; verify at least one rug dim is active
    rug_dims = [i for i, v in enumerate(vec[:56]) if v > 0]
    # mint is in the list, onlyOwner is in the list - just verify code is non-zero
    assert len(rug_dims) >= 1, "No rug pattern dims active in first 56 dims"
    # Also verify structural dims are populated (lines, functions, etc.)
    struct_dims = [i for i in range(32, 56) if vec[i] > 0]
    assert len(struct_dims) >= 1, f"No structural dims active: {struct_dims}"


@test("extract_contract_features: empty input returns zeros")
def test_contract_features_empty():
    from app.crypto_embeddings import extract_contract_features

    vec = extract_contract_features("")
    assert len(vec) == 128
    assert all(v == 0.0 for v in vec), "Empty input should produce zero vector"


@test("extract_transaction_features: returns 64-dim vector")
def test_transaction_features_dims():
    from app.crypto_embeddings import extract_transaction_features

    vec = extract_transaction_features({"transactions": []})
    assert len(vec) == 64, f"Expected 64 dims, got {len(vec)}"


@test("extract_transaction_features: captures counterparty diversity")
def test_transaction_features_counterparty():
    from app.crypto_embeddings import extract_transaction_features

    txs = [
        {"amount": 100, "from": "0xA", "to": "0xB", "timestamp": 1000},
        {"amount": 200, "from": "0xB", "to": "0xC", "timestamp": 2000},
    ]
    vec = extract_transaction_features({"transactions": txs})
    # dim 20 = counterparty diversity, should be > 0
    assert vec[20] > 0, "Counterparty diversity should be > 0"


@test("extract_wallet_features: returns 64-dim vector")
def test_wallet_features_dims():
    from app.crypto_embeddings import extract_wallet_features

    vec = extract_wallet_features({"labels": ["scammer"], "balance_usd": 5000})
    assert len(vec) == 64, f"Expected 64 dims, got {len(vec)}"


@test("extract_wallet_features: detects risk labels")
def test_wallet_features_labels():
    from app.crypto_embeddings import extract_wallet_features

    vec = extract_wallet_features({"labels": ["scammer", "rug_puller"]})
    # dim 20 = scammer, dim 21 = rug_puller
    assert vec[20] == 1.0, "Scammer label not detected at dim 20"
    assert vec[21] == 1.0, "Rug_puller label not detected at dim 21"


# ══════════════════════════════════════════════════════════════════════
# COSINE SIMILARITY
# ══════════════════════════════════════════════════════════════════════


@test("cosine_similarity: identical vectors = 1.0")
def test_cosine_same():
    from app.crypto_embeddings import CryptoEmbedder

    v = [1.0, 0.0, 0.5, 0.3]
    sim = CryptoEmbedder.cosine_similarity(v, v)
    assert abs(sim - 1.0) < 0.001, f"Same vector sim={sim}"


@test("cosine_similarity: orthogonal vectors = 0.0")
def test_cosine_orthogonal():
    from app.crypto_embeddings import CryptoEmbedder

    a = [1.0, 0.0, 0.0]
    b = [0.0, 1.0, 0.0]
    sim = CryptoEmbedder.cosine_similarity(a, b)
    assert abs(sim) < 0.001, f"Orthogonal sim={sim}"


@test("cosine_similarity: opposite vectors = -1.0")
def test_cosine_opposite():
    from app.crypto_embeddings import CryptoEmbedder

    a = [1.0, 0.0]
    b = [-1.0, 0.0]
    sim = CryptoEmbedder.cosine_similarity(a, b)
    assert abs(sim - (-1.0)) < 0.001, f"Opposite sim={sim}"


@test("cosine_similarity: zero vector = 0.0")
def test_cosine_zero():
    from app.crypto_embeddings import CryptoEmbedder

    sim = CryptoEmbedder.cosine_similarity([0, 0, 0], [1, 0, 0])
    assert sim == 0.0, f"Zero vector sim={sim}"


# ══════════════════════════════════════════════════════════════════════
# HASH EMBEDDING (no API needed)
# ══════════════════════════════════════════════════════════════════════


@test("hash_embed: returns 384-dim normalized vector")
def test_hash_embed():
    from app.crypto_embeddings import CryptoEmbedder

    embedder = CryptoEmbedder()
    vec = embedder._hash_embed("test wallet scam pattern")
    assert len(vec) == 384, f"Expected 384 dims, got {len(vec)}"
    # Should be L2-normalized
    import numpy as np

    norm = np.linalg.norm(vec)
    assert abs(norm - 1.0) < 0.01 or norm == 0.0, f"Norm={norm}"


@test("hash_embed: deterministic (same input = same output)")
def test_hash_embed_deterministic():
    from app.crypto_embeddings import CryptoEmbedder

    embedder = CryptoEmbedder()
    v1 = embedder._hash_embed("rug pull honeypot")
    v2 = embedder._hash_embed("rug pull honeypot")
    assert v1 == v2, "Hash embedding not deterministic"


@test("hash_embed: different inputs = different outputs")
def test_hash_embed_unique():
    from app.crypto_embeddings import CryptoEmbedder

    embedder = CryptoEmbedder()
    v1 = embedder._hash_embed("rug pull")
    v2 = embedder._hash_embed("wash trading")
    assert v1 != v2, "Different inputs produced same hash embedding"


# ══════════════════════════════════════════════════════════════════════
# BUNDLE/CLUSTER BEHAVIORAL EMBEDDING (pure functions)
# ══════════════════════════════════════════════════════════════════════


@test("embed_bundle_profile: returns 128-dim vector")
def test_bundle_profile():
    from app.bundle_cluster_rag import embed_bundle_profile

    bundle = {
        "confidence": 0.8,
        "atomic_block_score": 0.9,
        "common_funder_score": 0.7,
        "wallets_in_earliest_block": 15,
        "chain": "solana",
    }
    vec = embed_bundle_profile(bundle)
    assert len(vec) == 128, f"Expected 128 dims, got {len(vec)}"


@test("embed_bundle_profile: metrics at dims 80-85 NOT overwritten by hash")
def test_bundle_metrics_not_clobbered():
    from app.bundle_cluster_rag import embed_bundle_profile

    bundle = {
        "confidence": 0.9,
        "avg_buy_amount": 5000,
        "max_buy_amount": 10000,
        "profit_ratio": 5.0,
        "chain": "ethereum",
    }
    vec = embed_bundle_profile(bundle)
    # Dim 80 = avg_buy_amount/10000 = 0.5, should NOT be zero (overwritten by hash)
    assert vec[80] > 0, f"Metric at dim 80 is zero (clobbered by hash): {vec[80]}"
    assert vec[81] > 0, f"Metric at dim 81 is zero (clobbered by hash): {vec[81]}"


@test("embed_cluster_profile: returns 192-dim vector")
def test_cluster_profile():
    from app.bundle_cluster_rag import embed_cluster_profile

    cluster = {
        "size": 50,
        "density": 0.8,
        "total_volume_usd": 1000000,
        "scam_probability": 0.9,
        "active_chains": ["ethereum"],
    }
    vec = embed_cluster_profile(cluster)
    assert len(vec) == 192, f"Expected 192 dims, got {len(vec)}"


@test("embed_cluster_profile: risk scoring at dims 80-85")
def test_cluster_risk_scoring():
    from app.bundle_cluster_rag import embed_cluster_profile

    cluster = {"scam_probability": 0.9, "rug_probability": 0.8}
    vec = embed_cluster_profile(cluster)
    assert abs(vec[80] - 0.9) < 0.01, f"Scam prob at dim 80 wrong: {vec[80]}"
    assert abs(vec[81] - 0.8) < 0.01, f"Rug prob at dim 81 wrong: {vec[81]}"


# ══════════════════════════════════════════════════════════════════════
# ENTITY EXTRACTION (pure regex, no API)
# ══════════════════════════════════════════════════════════════════════


@test("extract_entities: EVM address detection")
def test_entity_evm():
    from app.entity_extraction import extract_entities

    result = extract_entities("Send funds to 0xdAC17F958D2ee523a2206206994597C13D831ec7")
    assert "0xdac17f958d2ee523a2206206994597c13d831ec7" in [
        a.lower() for a in result.evm_addresses
    ], f"EVM address not found: {result.evm_addresses}"


@test("extract_entities: token symbol detection")
def test_entity_symbol():
    from app.entity_extraction import extract_entities

    result = extract_entities("Price of $ETH and $SOL surging")
    symbols_upper = [s.upper() for s in result.token_symbols]
    assert "ETH" in symbols_upper or "$ETH" in result.token_symbols, (
        f"ETH not found: {result.token_symbols}"
    )
    assert "SOL" in symbols_upper or "$SOL" in result.token_symbols, (
        f"SOL not found: {result.token_symbols}"
    )


@test("extract_entities: chain name detection")
def test_entity_chain():
    from app.entity_extraction import extract_entities

    result = extract_entities("Bridged from Ethereum to Solana")
    assert "ethereum" in result.chain_names, f"Ethereum not found: {result.chain_names}"
    assert "solana" in result.chain_names, f"Solana not found: {result.chain_names}"


@test("extract_entities: scam keyword detection")
def test_entity_scam():
    from app.entity_extraction import extract_entities

    result = extract_entities("This token is a rug pull honeypot")
    assert "rug pull" in result.scam_keywords, f"rug pull not found: {result.scam_keywords}"


@test("extract_entities: ENS domain detection")
def test_entity_ens():
    from app.entity_extraction import extract_entities

    result = extract_entities("Send to vitalik.eth")
    assert "vitalik.eth" in result.ens_domains, f"ENS not found: {result.ens_domains}"


@test("extract_entities: empty input returns nothing")
def test_entity_empty():
    from app.entity_extraction import extract_entities

    result = extract_entities("Hello world this is a test")
    assert result.total_count == 0, f"Expected 0 entities, got {result.total_count}"


# ══════════════════════════════════════════════════════════════════════
# TEMPORAL DECAY (pure math)
# ══════════════════════════════════════════════════════════════════════


@test("compute_decay: fresh content = 1.0")
def test_decay_fresh():
    from app.temporal_decay import compute_decay

    assert compute_decay(0, 30) == 1.0
    assert compute_decay(0, 365) == 1.0


@test("compute_decay: infinite half-life = 1.0 always")
def test_decay_infinite():
    from app.temporal_decay import compute_decay

    assert compute_decay(1000, float("inf")) == 1.0
    assert compute_decay(0.1, float("inf")) == 1.0


@test("compute_decay: at half-life, score ~= 0.5")
def test_decay_halflife():
    from app.temporal_decay import compute_decay

    score = compute_decay(30.0, 30.0)  # 30 days old, 30-day half-life
    assert abs(score - 0.5) < 0.01, f"At half-life, expected ~0.5, got {score}"


@test("compute_decay: older = lower score")
def test_decay_monotonic():
    from app.temporal_decay import compute_decay

    s1 = compute_decay(10, 30)
    s2 = compute_decay(30, 30)
    s3 = compute_decay(60, 30)
    assert s1 > s2 > s3, f"Not monotonically decreasing: {s1} > {s2} > {s3}"


@test("apply_temporal_decay: adds decay fields to results")
def test_apply_decay_fields():
    from datetime import datetime

    from app.temporal_decay import apply_temporal_decay

    results = [
        {
            "similarity": 0.8,
            "collection": "news_articles",
            "stored_at": datetime.now(UTC).isoformat(),
        },
        {
            "similarity": 0.7,
            "collection": "forensic_reports",
            "stored_at": "2023-01-01T00:00:00+00:00",
        },
    ]
    decayed = apply_temporal_decay(results)
    assert "raw_similarity" in decayed[0], "Missing raw_similarity field"
    assert "decay_factor" in decayed[0], "Missing decay_factor field"
    assert "age_days" in decayed[0], "Missing age_days field"
    # Forensic report should keep 100% score (never decays)
    assert decayed[1]["decay_factor"] == 1.0, "Forensic report should never decay"


@test("get_half_life: forensic_reports = infinite")
def test_halflife_forensic():
    from app.temporal_decay import get_half_life

    hl = get_half_life("forensic_reports")
    assert hl == float("inf"), f"forensic_reports half-life should be inf, got {hl}"


@test("get_half_life: news_articles = 30 days")
def test_halflife_news():
    from app.temporal_decay import get_half_life

    hl = get_half_life("news_articles")
    assert hl == 30.0, f"news_articles half-life should be 30, got {hl}"


# ══════════════════════════════════════════════════════════════════════
# CONTEXTUAL CHUNKING
# ══════════════════════════════════════════════════════════════════════


@test("chunk_document: returns chunks with correct structure")
def test_chunk_document():
    from app.contextual_chunking import chunk_document

    text = "First paragraph.\n\nSecond paragraph. " * 100
    chunks = chunk_document(text, chunk_size=500, overlap=50)
    assert len(chunks) > 1, "Should produce multiple chunks"
    for c in chunks:
        assert hasattr(c, "content"), "Chunk missing content"
        assert hasattr(c, "index"), "Chunk missing index"
        assert len(c.content) > 0, "Chunk has empty content"


@test("chunk_document: single short doc produces one chunk")
def test_chunk_single():
    from app.contextual_chunking import chunk_document

    chunks = chunk_document("Short text.", chunk_size=2500)
    assert len(chunks) == 1, f"Short text should be 1 chunk, got {len(chunks)}"


@test("chunk_document: respects boundaries")
def test_chunk_boundaries():
    from app.contextual_chunking import chunk_document

    text = "# Section 1\n\nFirst section content here. " * 50
    chunks = chunk_document(text, chunk_size=500, overlap=50, respect_boundaries=True)
    # Should not cut mid-sentence
    for _c in chunks:
        # Content should not start mid-word (allowing for overlap)
        pass  # Basic validation that chunking completed


@test("heuristic context: generates context without LLM")
def test_heuristic_context():
    from app.contextual_chunking import _generate_heuristic_context

    ctx = _generate_heuristic_context(
        "# DeFi Analysis\n\nFull document text...", "chunk about exploits", 0, 5
    )
    assert "DeFi Analysis" in ctx, f"Title not in context: {ctx}"
    assert "Chunk 1 of 5" in ctx, f"Position not in context: {ctx}"


@test("parent_child_chunk: creates parent+child chunks")
def test_parent_child():
    from app.contextual_chunking import parent_child_chunk

    text = "First paragraph with enough text. " * 200
    chunks = parent_child_chunk(text, parent_size=500, child_size=200)
    parents = [c for c in chunks if c.metadata.get("is_parent")]
    children = [c for c in chunks if c.parent_id is not None]
    assert len(parents) > 0, "No parent chunks created"
    assert len(children) > 0, "No child chunks created"
    assert all(c.parent_content for c in children), "Children missing parent content"


# ══════════════════════════════════════════════════════════════════════
# EMBEDDING CACHE (requires Redis)
# ══════════════════════════════════════════════════════════════════════


@test("embedding cache: _cache_key generates deterministic keys")
async def test_cache_key():
    from app.crypto_embeddings import CryptoEmbedder

    embedder = CryptoEmbedder()
    key1 = await embedder._cache_key("semantic", "test text")
    key2 = await embedder._cache_key("semantic", "test text")
    assert key1 == key2, f"Cache keys not deterministic: {key1} != {key2}"


@test("embedding cache: different heads generate different keys")
async def test_cache_key_different_heads():
    from app.crypto_embeddings import CryptoEmbedder

    embedder = CryptoEmbedder()
    key1 = await embedder._cache_key("semantic", "test")
    key2 = await embedder._cache_key("code", "test")
    assert key1 != key2, f"Different heads should have different cache keys: {key1} == {key2}"


# ══════════════════════════════════════════════════════════════════════
# SUPABASE VECTOR STORE - SQL SAFETY (unit-level, no network)
# ══════════════════════════════════════════════════════════════════════


@test("_get_dim: auto-detects dimension from first embedding")
def test_get_dim_auto():
    from app.supabase_vector import SupabaseVectorStore

    store = SupabaseVectorStore()
    # EMBEDDING_DIM=0 means auto-detect
    dim = store._get_dim([0.1] * 384)
    assert dim == 384, f"Auto-detect should return 384, got {dim}"


@test("_get_dim: caches resolved dimension")
def test_get_dim_caches():
    from app.supabase_vector import SupabaseVectorStore

    store = SupabaseVectorStore()
    dim1 = store._get_dim([0.1] * 512)
    assert dim1 == 512, f"First call should return 512, got {dim1}"
    dim2 = store._get_dim([0.1] * 384)  # different length, but cached
    assert dim2 == 512, f"Should return cached 512, got {dim2}"


@test("_get_dim: fallback to 384 when no embedding provided")
def test_get_dim_fallback():
    from app.supabase_vector import SupabaseVectorStore

    store = SupabaseVectorStore()
    dim = store._get_dim()  # no embedding, no env var
    assert dim == 384, f"Default should be 384, got {dim}"


# ══════════════════════════════════════════════════════════════════════
# KNOWN SCAM PATTERNS DATA
# ══════════════════════════════════════════════════════════════════════


@test("KNOWN_SCAM_PATTERNS: has required fields")
def test_scam_patterns_structure():
    from app.crypto_embeddings import KNOWN_SCAM_PATTERNS

    assert len(KNOWN_SCAM_PATTERNS) >= 10, (
        f"Expected >= 10 patterns, got {len(KNOWN_SCAM_PATTERNS)}"
    )
    for p in KNOWN_SCAM_PATTERNS:
        assert "name" in p, f"Pattern missing name: {p}"
        assert "description" in p, f"Pattern missing description: {p}"
        assert "severity" in p, f"Pattern missing severity: {p}"
        assert p["severity"] in ("low", "medium", "high", "critical"), (
            f"Invalid severity: {p['severity']}"
        )


@test("CLUSTER_LABEL_TEMPLATES: has 10 templates")
def test_cluster_labels():
    from app.bundle_cluster_rag import CLUSTER_LABEL_TEMPLATES

    assert len(CLUSTER_LABEL_TEMPLATES) == 10, (
        f"Expected 10 templates, got {len(CLUSTER_LABEL_TEMPLATES)}"
    )
    labels = [t["label"] for t in CLUSTER_LABEL_TEMPLATES]
    assert "insider_trading_ring" in labels, "Missing insider_trading_ring"
    assert "wash_trading_farm" in labels, "Missing wash_trading_farm"
    assert "mev_bot_network" in labels, "Missing mev_bot_network"


# ══════════════════════════════════════════════════════════════════════
# RAG SERVICE - TTL MAP
# ══════════════════════════════════════════════════════════════════════


@test("TTL map: forensic_reports and scam_patterns have permanent TTL (0)")
def test_ttl_permanent():
    # Read the TTL map from ingest_document function
    # We verify by inspecting the source - TTL=0 means no expiry
    import inspect

    from app.rag_service import ingest_document

    source = inspect.getsource(ingest_document)
    assert "forensic_reports" in source, "forensic_reports missing from TTL logic"
    assert "scam_patterns" in source, "scam_patterns missing from TTL logic"


# ══════════════════════════════════════════════════════════════════════
# RERANK MODEL CONFIG
# ══════════════════════════════════════════════════════════════════════


@test("Rerank and analysis models are env-configurable")
def test_model_config():
    from app.rag_agentic import ANALYSIS_MODEL, RERANK_MODEL

    # Should have defaults
    assert RERANK_MODEL, "RERANK_MODEL should not be empty"
    assert ANALYSIS_MODEL, "ANALYSIS_MODEL should not be empty"
    # Should not be the old hardcoded broken names
    assert "claude-sonnet-4" not in ANALYSIS_MODEL or "20250514" in ANALYSIS_MODEL, (
        f"ANALYSIS_MODEL should use dated model, got: {ANALYSIS_MODEL}"
    )


# ══════════════════════════════════════════════════════════════════════
# COLLECTIONS LIST
# ══════════════════════════════════════════════════════════════════════


@test("COLLECTIONS list includes all expected collections")
def test_collections():
    from app.crypto_embeddings import COLLECTIONS

    expected = [
        "wallet_profiles",
        "token_analysis",
        "scam_patterns",
        "forensic_reports",
        "market_intel",
        "contract_audits",
        "known_scams",
        "news_articles",
        "transaction_patterns",
    ]
    for coll in expected:
        assert coll in COLLECTIONS, f"Missing collection: {coll}"


# ══════════════════════════════════════════════════════════════════════
# ANN INDEX (FAISS)
# ══════════════════════════════════════════════════════════════════════


@test("ANNIndex: build_index creates in-memory index")
async def test_ann_build():
    from app.ann_index import ANNIndex

    idx = ANNIndex()
    meta = await idx.build_index("scam_patterns")
    assert meta.get("status") == "built", f"Expected built, got {meta}"
    assert meta.get("n", 0) > 0, f"Expected docs > 0, got {meta.get('n')}"


@test("ANNIndex: search returns hydrated results with content")
async def test_ann_search():
    from app.ann_index import ANNIndex
    from app.crypto_embeddings import get_embedder

    idx = ANNIndex()
    await idx.build_index("scam_patterns")
    embedder = await get_embedder()
    query_vec = await embedder.embed_query("honeypot token")
    results = await idx.search(query_vec, "scam_patterns", limit=3, min_similarity=0.3)
    assert len(results) > 0, "No results returned"
    first = results[0]
    assert "id" in first, "Missing id"
    assert "similarity" in first, "Missing similarity"
    assert "content" in first, "Missing content (hydration failed)"


@test("ANNIndex: search is sub-second for small collections")
async def test_ann_speed():
    import time

    from app.ann_index import ANNIndex
    from app.crypto_embeddings import get_embedder

    idx = ANNIndex()
    await idx.build_index("scam_patterns")
    embedder = await get_embedder()
    query_vec = await embedder.embed_query("rug pull")
    t0 = time.time()
    await idx.search(query_vec, "scam_patterns", limit=5, min_similarity=0.3)
    t1 = time.time()
    assert (t1 - t0) < 1.0, f"Search took {t1 - t0:.2f}s, expected < 1s"


@test("ANNIndex: stats returns collection info")
async def test_ann_stats():
    from app.ann_index import ANNIndex

    idx = ANNIndex()
    await idx.build_index("scam_patterns")
    s = idx.stats()
    assert "scam_patterns" in s, f"scam_patterns not in stats: {s}"
    assert s["scam_patterns"].get("n", 0) > 0, "No vectors in stats"


# ══════════════════════════════════════════════════════════════════════
# SEMANTIC CACHE
# ══════════════════════════════════════════════════════════════════════


@test("SemanticCache: store and check returns hit")
async def test_semantic_cache_hit():
    from app.semantic_cache import SemanticCache

    cache = SemanticCache()
    vec = [0.1] * 384  # dummy vector
    results = [{"id": "test1", "similarity": 0.9, "content": "test doc"}]
    await cache.store(vec, results)
    cached = await cache.check(vec)
    assert cached is not None, "Expected cache hit, got None"
    assert len(cached) == 1, f"Expected 1 cached result, got {len(cached)}"


@test("SemanticCache: different vector returns miss")
async def test_semantic_cache_miss():
    from app.semantic_cache import SemanticCache

    cache = SemanticCache()
    vec1 = [1.0] + [0.0] * 383  # orthogonal
    await cache.store(vec1, [{"id": "x"}])
    vec2 = [0.0] + [1.0] * 1 + [0.0] * 382  # very different
    cached = await cache.check(vec2)
    assert cached is None, f"Expected cache miss, got hit: {cached}"


@test("SemanticCache: stats returns hit rate")
async def test_semantic_cache_stats():
    from app.semantic_cache import SemanticCache

    cache = SemanticCache()
    s = await cache.stats()
    assert "entries" in s, f"Missing entries in stats: {s}"
    assert "hit_rate" in s, f"Missing hit_rate in stats: {s}"


# ══════════════════════════════════════════════════════════════════════
# THREE-PILLAR HYBRID SEARCH
# ══════════════════════════════════════════════════════════════════════


@test("three_pillar_search: returns results with pillar attribution")
async def test_three_pillar():
    from app.rag_service import three_pillar_search

    result = await three_pillar_search(
        "honeypot token with high sell tax",
        collections=["scam_patterns"],
        limit=5,
    )
    assert "results" in result, f"Missing results key: {list(result.keys())}"
    assert "pillar_summary" in result, "Missing pillar_summary"
    ps = result["pillar_summary"]
    assert "dense_hits" in ps, "Missing dense_hits"
    assert "sparse_hits" in ps, "Missing sparse_hits"
    assert "pillars_used" in ps, "Missing pillars_used"


@test("three_pillar_search: entity extraction from query")
async def test_three_pillar_entity():
    from app.rag_service import three_pillar_search

    result = await three_pillar_search(
        "token on solana and ethereum",
        collections=["scam_patterns"],
        limit=3,
    )
    ee = result.get("entity_extraction")
    if ee:
        chains = ee.get("chain_names", [])
        assert "solana" in chains or "ethereum" in chains, f"Expected chain names, got: {chains}"


@test("three_pillar_search: RRF fusion produces ranked results")
async def test_three_pillar_rrf():
    from app.rag_service import three_pillar_search

    result = await three_pillar_search(
        "rug pull honeypot",
        collections=["scam_patterns"],
        limit=5,
    )
    results = result.get("results", [])
    assert len(results) > 0, "No results from three-pillar search"
    # Results should have match_type showing pillar attribution
    for r in results[:2]:
        assert "match_type" in r or "pillars" in r, f"Missing pillar attribution: {list(r.keys())}"


# ══════════════════════════════════════════════════════════════════════
# QUERY TRANSFORMATION
# ══════════════════════════════════════════════════════════════════════


@test("query_transform: expand adds crypto synonyms")
async def test_query_expand():
    from app.query_transform import expand_query

    variants = await expand_query("rug pull token on ethereum")
    assert len(variants) >= 3, f"Expected >=3 variants, got {len(variants)}: {variants}"
    # Should contain synonym expansions
    variants_lower = [v.lower() for v in variants]
    assert any(
        "honeypot" in v or "liquidity drain" in v or "exit scam" in v for v in variants_lower
    ), f"No crypto synonyms found in: {variants}"


@test("query_transform: step_back generalizes questions")
async def test_query_step_back():
    from app.query_transform import step_back_query

    result = await step_back_query("Is $SOL a rug pull?")
    # Should produce a broader query
    assert "rug pull" in result.lower() or "token" in result.lower(), (
        f"Step-back didn't generalize: {result}"
    )


@test("query_transform: auto router picks correct strategy")
async def test_query_auto_route():
    from app.query_transform import transform_query

    # Specific entity (address) → should pick expand or step_back
    tq = await transform_query("0xdAC17F958D2ee523a2206206994597C13D831ec7 scam", strategy="auto")
    assert tq.strategy != "none", f"Entity query should not be 'none', got {tq.strategy}"
    assert len(tq.transformed_queries) >= 1, f"Should have transforms: {tq.transformed_queries}"


@test("query_transform: short factual queries pass through")
async def test_query_passthrough():
    from app.query_transform import transform_query

    tq = await transform_query("rug pull", strategy="none")
    assert tq.strategy == "none"
    assert tq.transformed_queries == ["rug pull"]


# ══════════════════════════════════════════════════════════════════════
# RAGAS EVALUATION
# ══════════════════════════════════════════════════════════════════════


@test("ragas_eval: golden test set has entries")
def test_ragas_golden_set():
    from app.ragas_eval import GOLDEN_TEST_SET

    assert len(GOLDEN_TEST_SET) >= 40, (
        f"Expected >=40 golden test pairs, got {len(GOLDEN_TEST_SET)}"
    )
    for entry in GOLDEN_TEST_SET:
        assert "query" in entry, f"Missing query field: {entry}"
        assert "collection" in entry, f"Missing collection field: {entry}"


@test("ragas_eval: context_precision computes nDCG")
def test_ragas_ndcg():
    from app.ragas_eval import context_precision

    # Perfect ranking
    prec = context_precision(["a", "b", "c"], ["a", "b", "c"], k=5)
    assert prec == 1.0, f"Perfect ranking should be 1.0, got {prec}"
    # Empty results
    prec0 = context_precision([], ["a", "b"], k=5)
    assert prec0 == 0.0, f"Empty results should be 0.0, got {prec0}"


@test("ragas_eval: hit_rate computes correctly")
def test_ragas_hit_rate():
    from app.ragas_eval import hit_rate

    hr = hit_rate(["a", "b", "c"], ["b"])
    assert hr == 1.0, f"Hit should be 1.0 when relevant doc in results, got {hr}"
    hr0 = hit_rate(["x", "y"], ["b"])
    assert hr0 == 0.0, f"Hit should be 0.0 when no relevant doc, got {hr0}"


@test("ragas_eval: mrr computes correctly")
def test_ragas_mrr():
    from app.ragas_eval import mrr

    mr = mrr(["x", "b", "y"], ["b"])
    assert abs(mr - 0.5) < 0.01, f"MRR for rank-2 hit should be 0.5, got {mr}"
    mr0 = mrr(["x", "y"], ["b"])
    assert mr0 == 0.0, f"MRR for no hit should be 0.0, got {mr0}"


# ══════════════════════════════════════════════════════════════════════
# SCAM PATTERN CACHE
# ══════════════════════════════════════════════════════════════════════


@test("scam pattern cache: pre_embed caches all 10 patterns")
async def test_pattern_cache():
    from app.crypto_embeddings import KNOWN_SCAM_PATTERNS
    from app.rag_service import _pattern_cache, _preembed_scam_patterns

    await _preembed_scam_patterns()
    assert len(_pattern_cache) == len(KNOWN_SCAM_PATTERNS), (
        f"Expected {len(KNOWN_SCAM_PATTERNS)} cached, got {len(_pattern_cache)}"
    )


@test("scam pattern cache: cached patterns have embedding vectors")
async def test_pattern_cache_vectors():
    from app.rag_service import _pattern_cache, _preembed_scam_patterns

    await _preembed_scam_patterns()
    for name, result in _pattern_cache.items():
        assert hasattr(result, "vector"), f"Pattern {name} missing vector attr"
        assert len(result.vector) > 0, f"Pattern {name} has empty vector"


# ══════════════════════════════════════════════════════════════════════
# MAIN
# ══════════════════════════════════════════════════════════════════════

if __name__ == "__main__":
    print("=" * 60)
    print("  RAG SYSTEM TEST SUITE")
    print("=" * 60)
    ok = asyncio.run(run_tests())
    sys.exit(0 if ok else 1)
