"""Tests for the OpenSearch adapter (infra.opensearch_store).

These tests mock the AsyncOpenSearch client to validate adapter logic
without requiring a running OpenSearch instance.
"""

from __future__ import annotations

from unittest.mock import AsyncMock

import pytest

from domain.ports import VectorStore
from domain.vector_schema import (
    ChunkBboxEntry,
    ChunkOrigin,
    DocItemRef,
    IndexedChunk,
    SearchResult,
    build_index_mapping,
)
from infra.opensearch_store import OpenSearchStore, _hit_to_indexed_chunk, _hit_to_result

# -- Fixtures -----------------------------------------------------------------


def _make_chunk(
    doc_id: str = "doc-1",
    chunk_index: int = 0,
    content: str = "hello world",
    embedding: list[float] | None = None,
) -> IndexedChunk:
    return IndexedChunk(
        doc_id=doc_id,
        filename="test.pdf",
        content=content,
        embedding=embedding or [0.1, 0.2, 0.3],
        chunk_index=chunk_index,
        chunk_type="text",
        page_number=1,
        bboxes=[ChunkBboxEntry(page=1, x=0.0, y=0.0, w=100.0, h=50.0)],
        headings=["Chapter 1"],
        doc_items=[DocItemRef(self_ref="#/texts/0", label="text")],
        origin=ChunkOrigin(binary_hash="abc123", filename="test.pdf"),
    )


def _make_hit(
    doc_id: str = "doc-1",
    chunk_index: int = 0,
    score: float = 0.95,
    content: str = "hello world",
) -> dict:
    return {
        "_id": f"{doc_id}_{chunk_index}",
        "_score": score,
        "_source": {
            "doc_id": doc_id,
            "filename": "test.pdf",
            "content": content,
            "chunk_index": chunk_index,
            "chunk_type": "text",
            "page_number": 1,
            "bboxes": [{"page": 1, "x": 0.0, "y": 0.0, "w": 100.0, "h": 50.0}],
            "headings": ["Chapter 1"],
            "doc_items": [{"self_ref": "#/texts/0", "label": "text"}],
            "origin": {"binary_hash": "abc123", "filename": "test.pdf"},
        },
    }


@pytest.fixture
def store() -> OpenSearchStore:
    return OpenSearchStore("http://localhost:9200")


@pytest.fixture
def mock_client(store: OpenSearchStore) -> AsyncMock:
    client = AsyncMock()
    store._client = client
    return client


# -- Protocol satisfaction -----------------------------------------------------


class TestProtocolSatisfaction:
    def test_satisfies_vector_store_protocol(self) -> None:
        """OpenSearchStore structurally satisfies VectorStore Protocol."""
        store = OpenSearchStore("http://localhost:9200")
        assert isinstance(store, VectorStore)


# -- Hit deserialization -------------------------------------------------------


class TestHitDeserialization:
    def test_hit_to_indexed_chunk(self) -> None:
        hit = _make_hit()
        chunk = _hit_to_indexed_chunk(hit)
        assert isinstance(chunk, IndexedChunk)
        assert chunk.doc_id == "doc-1"
        assert chunk.content == "hello world"
        assert chunk.chunk_index == 0
        assert chunk.page_number == 1
        assert len(chunk.bboxes) == 1
        assert chunk.bboxes[0].w == 100.0
        assert len(chunk.doc_items) == 1
        assert chunk.doc_items[0].label == "text"
        assert chunk.origin is not None
        assert chunk.origin.binary_hash == "abc123"

    def test_hit_to_indexed_chunk_no_origin(self) -> None:
        hit = _make_hit()
        hit["_source"]["origin"] = None
        chunk = _hit_to_indexed_chunk(hit)
        assert chunk.origin is None

    def test_hit_to_indexed_chunk_missing_optional_fields(self) -> None:
        hit = _make_hit()
        del hit["_source"]["bboxes"]
        del hit["_source"]["headings"]
        del hit["_source"]["doc_items"]
        del hit["_source"]["origin"]
        chunk = _hit_to_indexed_chunk(hit)
        assert chunk.bboxes == []
        assert chunk.headings == []
        assert chunk.doc_items == []
        assert chunk.origin is None

    def test_hit_to_result(self) -> None:
        hit = _make_hit(score=0.87)
        result = _hit_to_result(hit)
        assert isinstance(result, SearchResult)
        assert result.score == 0.87
        assert result.chunk.doc_id == "doc-1"


# -- ensure_index --------------------------------------------------------------


class TestEnsureIndex:
    async def test_creates_index_when_not_exists(
        self, store: OpenSearchStore, mock_client: AsyncMock
    ) -> None:
        mock_client.indices.exists.return_value = False
        mapping = build_index_mapping()
        await store.ensure_index("test-index", mapping)
        mock_client.indices.create.assert_awaited_once_with(index="test-index", body=mapping)

    async def test_noop_when_index_exists(
        self, store: OpenSearchStore, mock_client: AsyncMock
    ) -> None:
        mock_client.indices.exists.return_value = True
        await store.ensure_index("test-index", {})
        mock_client.indices.create.assert_not_awaited()


# -- index_chunks --------------------------------------------------------------


class TestIndexChunks:
    async def test_bulk_indexes_chunks(
        self, store: OpenSearchStore, mock_client: AsyncMock
    ) -> None:
        chunks = [_make_chunk(chunk_index=0), _make_chunk(chunk_index=1)]
        mock_client.bulk.return_value = {
            "errors": False,
            "items": [
                {"index": {"_id": "doc-1_0", "status": 201}},
                {"index": {"_id": "doc-1_1", "status": 201}},
            ],
        }
        count = await store.index_chunks("test-index", chunks)
        assert count == 2
        mock_client.bulk.assert_awaited_once()

    async def test_returns_zero_for_empty_list(
        self, store: OpenSearchStore, mock_client: AsyncMock
    ) -> None:
        count = await store.index_chunks("test-index", [])
        assert count == 0
        mock_client.bulk.assert_not_awaited()

    async def test_counts_partial_failures(
        self, store: OpenSearchStore, mock_client: AsyncMock
    ) -> None:
        chunks = [_make_chunk(chunk_index=0), _make_chunk(chunk_index=1)]
        mock_client.bulk.return_value = {
            "errors": True,
            "items": [
                {"index": {"_id": "doc-1_0", "status": 201}},
                {"index": {"_id": "doc-1_1", "error": {"reason": "mapping"}}},
            ],
        }
        count = await store.index_chunks("test-index", chunks)
        assert count == 1

    async def test_bulk_body_structure(
        self, store: OpenSearchStore, mock_client: AsyncMock
    ) -> None:
        chunk = _make_chunk(doc_id="d1", chunk_index=3)
        mock_client.bulk.return_value = {
            "errors": False,
            "items": [{"index": {"_id": "d1_3", "status": 201}}],
        }
        await store.index_chunks("idx", [chunk])
        call_body = mock_client.bulk.call_args[1]["body"]
        assert call_body[0] == {"index": {"_index": "idx", "_id": "d1_3"}}
        assert call_body[1]["doc_id"] == "d1"
        assert call_body[1]["chunk_index"] == 3


# -- search_similar ------------------------------------------------------------


class TestSearchSimilar:
    async def test_knn_search(self, store: OpenSearchStore, mock_client: AsyncMock) -> None:
        mock_client.search.return_value = {"hits": {"hits": [_make_hit(score=0.99)]}}
        results = await store.search_similar("idx", [0.1, 0.2, 0.3], k=5)
        assert len(results) == 1
        assert results[0].score == 0.99
        call_body = mock_client.search.call_args[1]["body"]
        assert call_body["query"]["knn"]["embedding"]["vector"] == [0.1, 0.2, 0.3]
        assert call_body["query"]["knn"]["embedding"]["k"] == 5

    async def test_knn_search_with_doc_filter(
        self, store: OpenSearchStore, mock_client: AsyncMock
    ) -> None:
        mock_client.search.return_value = {"hits": {"hits": []}}
        await store.search_similar("idx", [0.1], doc_id="doc-42")
        call_body = mock_client.search.call_args[1]["body"]
        assert call_body["query"]["knn"]["embedding"]["filter"] == {"term": {"doc_id": "doc-42"}}

    async def test_knn_search_no_filter_by_default(
        self, store: OpenSearchStore, mock_client: AsyncMock
    ) -> None:
        mock_client.search.return_value = {"hits": {"hits": []}}
        await store.search_similar("idx", [0.1])
        call_body = mock_client.search.call_args[1]["body"]
        assert "filter" not in call_body["query"]["knn"]["embedding"]


# -- get_chunks ----------------------------------------------------------------


class TestGetChunks:
    async def test_retrieves_by_doc_id(
        self, store: OpenSearchStore, mock_client: AsyncMock
    ) -> None:
        mock_client.search.return_value = {
            "hits": {"hits": [_make_hit(chunk_index=0), _make_hit(chunk_index=1)]}
        }
        results = await store.get_chunks("idx", "doc-1")
        assert len(results) == 2
        call_body = mock_client.search.call_args[1]["body"]
        assert call_body["query"] == {"term": {"doc_id": "doc-1"}}
        assert call_body["sort"] == [{"chunk_index": {"order": "asc"}}]

    async def test_respects_limit(self, store: OpenSearchStore, mock_client: AsyncMock) -> None:
        mock_client.search.return_value = {"hits": {"hits": []}}
        await store.get_chunks("idx", "doc-1", limit=50)
        call_body = mock_client.search.call_args[1]["body"]
        assert call_body["size"] == 50


# -- delete_document -----------------------------------------------------------


class TestDeleteDocument:
    async def test_deletes_by_doc_id(self, store: OpenSearchStore, mock_client: AsyncMock) -> None:
        mock_client.delete_by_query.return_value = {"deleted": 5}
        count = await store.delete_document("idx", "doc-1")
        assert count == 5
        call_body = mock_client.delete_by_query.call_args[1]["body"]
        assert call_body["query"] == {"term": {"doc_id": "doc-1"}}

    async def test_returns_zero_on_not_found(
        self, store: OpenSearchStore, mock_client: AsyncMock
    ) -> None:
        from opensearchpy import NotFoundError

        mock_client.delete_by_query.side_effect = NotFoundError(404, "index_not_found")
        count = await store.delete_document("idx", "doc-1")
        assert count == 0


# -- search_fulltext -----------------------------------------------------------


class TestSearchFulltext:
    async def test_fulltext_search(self, store: OpenSearchStore, mock_client: AsyncMock) -> None:
        mock_client.search.return_value = {
            "hits": {"hits": [_make_hit(content="matching text", score=1.5)]}
        }
        results = await store.search_fulltext("idx", "matching")
        assert len(results) == 1
        assert results[0].chunk.content == "matching text"
        call_body = mock_client.search.call_args[1]["body"]
        assert {"match": {"content": "matching"}} in call_body["query"]["bool"]["must"]

    async def test_fulltext_search_with_doc_filter(
        self, store: OpenSearchStore, mock_client: AsyncMock
    ) -> None:
        mock_client.search.return_value = {"hits": {"hits": []}}
        await store.search_fulltext("idx", "query", doc_id="doc-5")
        call_body = mock_client.search.call_args[1]["body"]
        must_clauses = call_body["query"]["bool"]["must"]
        assert {"term": {"doc_id": "doc-5"}} in must_clauses


# -- close ---------------------------------------------------------------------


class TestClose:
    async def test_close_delegates_to_client(
        self, store: OpenSearchStore, mock_client: AsyncMock
    ) -> None:
        await store.close()
        mock_client.close.assert_awaited_once()
