"""Tests for the DenseMemory backend.

These tests exercise retrieval quality on a small fixture corpus, then
assert on the actual cosine-similarity score distribution the embedding
model produces. The thresholds here are set **empirically** from the
observed scores on nomic-embed-text — not guessed upfront — so a
regression in either the embedder or the chunker will show up as a
failing assertion rather than a silently bad result.

The tests require Ollama with ``nomic-embed-text`` pulled; they are
skipped if the server is unreachable.
"""

from __future__ import annotations

import os
import socket
from pathlib import Path

import pytest

from openjarvis.tools.storage.dense import (
    DenseMemory,
    MdChunk,
    chunk_markdown,
    dedupe_chunks,
)

_FIXTURE_DIR = Path(__file__).resolve().parents[2] / "fixtures" / "docs"
_OLLAMA_HOST = os.environ.get("OLLAMA_HOST", "localhost")
_OLLAMA_PORT = int(os.environ.get("OLLAMA_PORT", "11434"))


def _ollama_up() -> bool:
    try:
        with socket.create_connection((_OLLAMA_HOST, _OLLAMA_PORT), timeout=1.0):
            return True
    except OSError:
        return False


ollama_required = pytest.mark.skipif(
    not _ollama_up(),
    reason=(
        "Requires Ollama with nomic-embed-text "
        "(start `ollama serve` then `ollama pull nomic-embed-text`)"
    ),
)


# ---------------------------------------------------------------------------
# Chunking unit tests (no Ollama required)
# ---------------------------------------------------------------------------


class TestChunkMarkdown:
    def test_empty_text(self):
        assert chunk_markdown("") == []
        assert chunk_markdown("   \n\n  ") == []

    def test_single_section_without_splits(self):
        md = (
            "# Title\n\nSome body paragraph with a few sentences. Enough to be a chunk."
        )
        chunks = chunk_markdown(md, source="t.md")
        assert len(chunks) == 1
        assert chunks[0].breadcrumb == "Title"
        assert "body paragraph" in chunks[0].content

    def test_splits_on_h2_and_h3(self):
        md = (
            "# Book\n\n"
            "## Chapter One\n\nfirst body.\n\n"
            "### Section A\n\nalpha body.\n\n"
            "### Section B\n\nbeta body.\n\n"
            "## Chapter Two\n\ngamma body.\n"
        )
        chunks = chunk_markdown(md, source="t.md")
        breadcrumbs = [c.breadcrumb for c in chunks]
        # Every header change becomes a new chunk
        assert "Book > Chapter One" in breadcrumbs
        assert "Book > Chapter One > Section A" in breadcrumbs
        assert "Book > Chapter One > Section B" in breadcrumbs
        assert "Book > Chapter Two" in breadcrumbs

    def test_ignores_headers_inside_code_fences(self):
        # Inside a fenced code block, lines starting with '#' are
        # shell/python comments, not markdown headers.
        md = (
            "# Guide\n\n"
            "## Install\n\n"
            "Run these commands:\n\n"
            "```bash\n"
            "# Install Homebrew\n"
            "# Build the Rust extension\n"
            "brew install rust\n"
            "```\n\n"
            "Then verify.\n"
        )
        chunks = chunk_markdown(md, source="t.md")
        breadcrumbs = [c.breadcrumb for c in chunks]
        assert "Guide > Install" in breadcrumbs
        # Must NOT have parsed the shell comments as headers:
        assert not any("Install Homebrew" in b for b in breadcrumbs)
        assert not any("Build the Rust extension" in b for b in breadcrumbs)

    def test_oversize_section_is_split_with_overlap(self):
        body = " ".join(["word"] * 2500)
        md = f"# Big\n\n## Section\n\n{body}\n"
        chunks = chunk_markdown(
            md,
            source="t.md",
            max_section_tokens=500,
            paragraph_overlap_tokens=50,
        )
        assert len(chunks) >= 2
        for c in chunks:
            # Every chunk carries the breadcrumb
            assert c.breadcrumb == "Big > Section"


# ---------------------------------------------------------------------------
# Cross-file deduplication (no Ollama required)
# ---------------------------------------------------------------------------


def _mk(content: str, source: str) -> MdChunk:
    return MdChunk(content=f"Header\n\n{content}", source=source, breadcrumb="Header")


class TestDedupeChunks:
    def test_no_dedupe_when_under_threshold_files(self):
        """Two identical chunks across two files should NOT be deduped (need 3+)."""
        chunks = [
            _mk("the quick brown fox jumps over the lazy dog every morning", "a.md"),
            _mk("the quick brown fox jumps over the lazy dog every morning", "b.md"),
        ]
        survivors, report = dedupe_chunks(chunks, min_files_for_dup=3)
        assert len(survivors) == 2
        assert report.removed_count == 0

    def test_dedupes_boilerplate_across_three_files(self):
        """Same blurb in 3+ files → keep one canonical, drop the rest."""
        body = "openjarvis runs entirely on your hardware no cloud needed local first"
        chunks = [
            _mk(body, "docs/index.md"),
            _mk(body, "docs/downloads.md"),
            _mk(body, "docs/getting-started/installation.md"),
        ]
        survivors, report = dedupe_chunks(chunks, min_files_for_dup=3)
        assert len(survivors) == 1
        # Most-specific source path wins (deepest)
        assert survivors[0].source == "docs/getting-started/installation.md"
        assert report.removed_count == 2
        assert len(report.groups) == 1
        grp = report.groups[0]
        assert grp.distinct_files == 3
        assert "docs/index.md" in grp.dropped_sources
        assert "docs/downloads.md" in grp.dropped_sources

    def test_keeps_distinct_content(self):
        """Genuinely different chunks must survive even with shared phrases."""
        chunks = [
            _mk(
                "install ollama with brew install ollama then run ollama serve",
                "a.md",
            ),
            _mk(
                "configure vllm with tensor parallelism and prefix caching",
                "b.md",
            ),
            _mk(
                "llama.cpp builds with cmake and supports cpu metal cuda rocm",
                "c.md",
            ),
        ]
        survivors, report = dedupe_chunks(chunks)
        assert len(survivors) == 3
        assert report.removed_count == 0

    def test_minor_edit_is_clustered_when_body_is_long_enough(self):
        """A one-word swap in a long boilerplate paragraph still clusters.

        At 5-grams a one-word change kills 5 n-grams; in a short 14-word
        sentence that's half the grams (Jaccard ~0.33 — below the 0.7
        threshold), but in real boilerplate paragraphs the change is a
        small fraction of total grams and the cluster still forms. This
        test uses a paragraph long enough to put Jaccard above 0.7.
        """
        common = (
            "openjarvis is a personal ai platform that runs entirely on your "
            "own hardware no cloud apis required by default the project is "
            "open source apache 2 licensed and supports ollama vllm sglang "
            "and llama cpp inference engines with auto detection of your "
            "available compute resources at startup so the right backend is "
            "picked without manual configuration in most cases"
        )
        # One-word change shouldn't break clustering on this length
        a = common + " choose the interface that suits you"
        b = common + " pick the interface that suits you"
        c = common + " select the interface that suits you"
        chunks = [_mk(a, "a.md"), _mk(b, "b.md"), _mk(c, "c.md")]
        survivors, report = dedupe_chunks(chunks)
        assert len(survivors) == 1, (
            f"got {len(survivors)} survivors — expected single cluster from boilerplate"
        )
        assert report.removed_count == 2

    def test_breadcrumb_difference_does_not_block_dedupe(self):
        """Identical body wrapped in different breadcrumbs still dedupes.

        Without stripping the breadcrumb prefix before n-gram extraction,
        chunks with the same body but different leading words (e.g.
        ``Downloads`` vs ``Installation``) would have lower Jaccard.
        """
        body = (
            "openjarvis runs entirely on your hardware no cloud needed "
            "local first foundation"
        )
        chunks = [
            MdChunk(
                content=f"Downloads\n\n{body}",
                source="docs/downloads.md",
                breadcrumb="Downloads",
            ),
            MdChunk(
                content=f"Installation\n\n{body}",
                source="docs/install.md",
                breadcrumb="Installation",
            ),
            MdChunk(
                content=f"Welcome\n\n{body}",
                source="docs/index.md",
                breadcrumb="Welcome",
            ),
        ]
        survivors, report = dedupe_chunks(chunks, min_files_for_dup=3)
        assert len(survivors) == 1, (
            f"got {len(survivors)} survivors: {[s.source for s in survivors]}"
        )
        assert report.removed_count == 2

    def test_path_specificity_tiebreaker(self):
        """When duplicates exist, the deepest path wins."""
        body = (
            "we use the orchestrator agent backed by tools and memory "
            "backends configured per recipe"
        )
        chunks = [
            _mk(body, "shallow.md"),
            _mk(body, "docs/middle.md"),
            _mk(body, "docs/getting-started/installation.md"),
        ]
        survivors, _ = dedupe_chunks(chunks, min_files_for_dup=3)
        assert len(survivors) == 1
        assert survivors[0].source == "docs/getting-started/installation.md"

    def test_empty_input(self):
        survivors, report = dedupe_chunks([])
        assert survivors == []
        assert report.input_count == 0
        assert report.output_count == 0

    def test_does_not_remove_more_than_corpus(self):
        """Sanity: removed_count <= input_count, output_count >= 1 per cluster."""
        body = "boilerplate about how openjarvis runs entirely on your hardware locally"
        chunks = [_mk(body, f"f{i}.md") for i in range(10)]
        survivors, report = dedupe_chunks(chunks, min_files_for_dup=3)
        # 10 files all duplicates → 1 survives
        assert len(survivors) == 1
        assert report.removed_count == 9
        assert report.output_count + report.removed_count == report.input_count


# ---------------------------------------------------------------------------
# Retrieval quality tests (require Ollama + nomic-embed-text)
# ---------------------------------------------------------------------------


@pytest.fixture(scope="module")
def indexed_backend():
    """DenseMemory populated from the fixture corpus once per module."""
    if not _ollama_up():
        pytest.skip("Ollama not reachable")

    backend = DenseMemory()
    md_files = sorted(_FIXTURE_DIR.glob("*.md"))
    assert md_files, f"no fixtures at {_FIXTURE_DIR}"

    contents, sources, metadatas = [], [], []
    for f in md_files:
        for chunk in chunk_markdown(f.read_text(encoding="utf-8"), source=f.name):
            contents.append(chunk.content)
            sources.append(chunk.source)
            metadatas.append({"breadcrumb": chunk.breadcrumb})
    backend.store_many(contents, sources=sources, metadatas=metadatas)
    return backend


@ollama_required
def test_index_built_with_expected_chunk_count(indexed_backend):
    # 4 fixture files with small sections should give a modest chunk count —
    # not 1 (indexing broken), not 100 (splitting gone wild).
    n = indexed_backend.count()
    assert 8 <= n <= 25, f"unexpected chunk count: {n}"


# Threshold chosen empirically from the score distribution documented in
# test_score_distribution_vs_threshold below. See twitter_bot.py for the
# shared constant used at runtime.
_SCORE_THRESHOLD = 0.55


@ollama_required
def test_exact_match_is_top_hit(indexed_backend):
    """A query lifted verbatim from the corpus should return that chunk at rank 1.

    We avoid generic terms like "backends" (which match both memory.md
    and engines.md) and use a phrase that's distinctive in one doc.
    """
    results = indexed_backend.retrieve("BM25 and FAISS for memory retrieval", top_k=3)
    assert results, "expected at least one hit"
    assert results[0].source == "memory.md"
    assert "bm25" in results[0].content.lower() or "faiss" in results[0].content.lower()
    # Verbatim-ish matches against nomic-embed-text cluster around 0.7+
    assert results[0].score > 0.65, f"top score too low: {results[0].score}"


@ollama_required
def test_paraphrase_matches_semantically(indexed_backend):
    """A paraphrase of doc content should still retrieve an on-topic chunk.

    Note: for the "can I run this on a laptop without a gpu?" case the
    top hit is ``engines.md > llama.cpp`` (not ``hardware.md > Running
    Without a GPU``) because the llama.cpp section explicitly says
    "ideal for laptops without a discrete GPU" — a near-literal match
    for the query. That's fine for grounding: both chunks contain the
    facts we need (CPU-only, llama.cpp).
    """
    results = indexed_backend.retrieve(
        "can I run this on a laptop without a gpu?",
        top_k=3,
    )
    assert results, "expected at least one hit"
    # Top-3 should all be from the topical docs (engines.md or hardware.md)
    topical = {r.source for r in results[:3]}
    assert topical <= {"engines.md", "hardware.md"}, (
        f"off-topic sources in top-3: {topical}"
    )
    top_lc = results[0].content.lower()
    assert "llama.cpp" in top_lc or "cpu" in top_lc


@ollama_required
def test_engine_query_finds_engines_doc(indexed_backend):
    """Semantic query about inference engines should find engines.md."""
    results = indexed_backend.retrieve(
        "which backend is best for high throughput serving?",
        top_k=3,
    )
    assert results
    assert results[0].source == "engines.md"
    assert results[0].score > 0.65


@ollama_required
def test_off_topic_query_scores_below_threshold(indexed_backend):
    """Clearly off-topic queries must fall below the router's threshold.

    This is the property the Twitter bot depends on: when the user asks
    something unrelated to the docs, retrieval must score below
    ``_SCORE_THRESHOLD`` so the bot chooses the deferral path instead of
    grounding on nonsense.

    Note: ``nomic-embed-text`` inflates off-topic scores (observed
    up to ~0.51 on this corpus) — a plain sentence-transformer would
    give a wider gap, but we're optimizing for the in-process embedder
    we actually have.
    """
    off_topic_queries = [
        "how do I bake a chocolate chip cookie",
        "what is the capital of Mongolia",
        "recommend me a pop song",
        "why is the sky blue",
    ]
    for q in off_topic_queries:
        hits = indexed_backend.retrieve(q, top_k=1)
        assert hits, f"expected any hit for {q!r}"
        assert hits[0].score < _SCORE_THRESHOLD, (
            f"off-topic query {q!r} scored {hits[0].score:.3f} — above "
            f"threshold {_SCORE_THRESHOLD}, would trigger a false-positive "
            f"ground. Tune threshold up or expand corpus."
        )


@ollama_required
def test_score_distribution_vs_threshold(indexed_backend, capsys):
    """Document the observed score distribution so the threshold is audit-able.

    We do NOT assert ``rel_min > off_max`` — nomic-embed-text produces
    overlapping ranges on small narrow corpora. Instead we verify that
    the chosen threshold cleanly separates the two *medians*, which is
    the property we actually rely on at the router: **most** relevant
    queries ground and **all** off-topic queries defer.
    """
    relevant_queries = [
        "BM25 FAISS Hybrid backends",
        "can I run this on a laptop without a gpu?",
        "which backend is best for high throughput serving?",
        "how do I add a new channel integration",
        "what happens when i store conflicting facts?",
    ]
    off_topic_queries = [
        "how do I bake a chocolate chip cookie",
        "what is the capital of Mongolia",
        "recommend me a pop song",
        "why is the sky blue",
    ]

    def _top1(q: str) -> float:
        hits = indexed_backend.retrieve(q, top_k=1)
        return hits[0].score

    rel = sorted(_top1(q) for q in relevant_queries)
    off = sorted(_top1(q) for q in off_topic_queries)

    rel_median = rel[len(rel) // 2]
    off_median = off[len(off) // 2]

    print(f"\n  relevant scores:   {[round(s, 3) for s in rel]}")
    print(f"  off-topic scores:  {[round(s, 3) for s in off]}")
    print(f"  relevant median:   {rel_median:.3f}")
    print(f"  off-topic median:  {off_median:.3f}")
    print(f"  chosen threshold:  {_SCORE_THRESHOLD}")

    # Medians must be cleanly separated by the threshold
    assert rel_median > _SCORE_THRESHOLD, (
        f"relevant median {rel_median:.3f} <= threshold {_SCORE_THRESHOLD} — "
        f"too many relevant queries will be deferred."
    )
    assert off_median < _SCORE_THRESHOLD, (
        f"off-topic median {off_median:.3f} >= threshold {_SCORE_THRESHOLD} — "
        f"too many nonsense queries will ground."
    )


# ---------------------------------------------------------------------------
# API-level sanity
# ---------------------------------------------------------------------------


class TestDenseMemoryAPI:
    @ollama_required
    def test_store_and_delete(self):
        backend = DenseMemory()
        doc_id = backend.store("the cat sat on the mat", source="a.txt")
        assert backend.count() == 1
        hits = backend.retrieve("where is the cat", top_k=1)
        assert hits and hits[0].metadata["doc_id"] == doc_id
        assert backend.delete(doc_id)
        assert backend.count() == 0
        assert not backend.delete(doc_id)

    @ollama_required
    def test_empty_retrieve(self):
        backend = DenseMemory()
        assert backend.retrieve("anything", top_k=3) == []

    @ollama_required
    def test_clear(self):
        backend = DenseMemory()
        backend.store("foo")
        backend.store("bar")
        backend.clear()
        assert backend.count() == 0
        assert backend.retrieve("foo", top_k=1) == []
