from unittest.mock import MagicMock, patch

from app.services.catalog import products as mod
from app.services.catalog.products import MAX_RELATED_PRODUCTS, normalise_url, to_card


def test_tracking_parameters_are_stripped():
    assert normalise_url("https://s.com/p/1?utm_source=g&utm_medium=cpc") == "https://s.com/p/1"
    assert normalise_url("https://s.com/p/1?gclid=abc") == "https://s.com/p/1"
    assert normalise_url("https://s.com/p/1?fbclid=x&ref=y") == "https://s.com/p/1"


def test_real_query_parameters_are_kept():
    """Many shops identify the product in the query string. Stripping it would
    collapse every product onto one URL and recommend the wrong thing."""
    assert normalise_url("https://s.com/p?product_id=123") == "https://s.com/p?product_id=123"
    assert normalise_url("https://s.com/p?product_id=123&utm_source=g") == "https://s.com/p?product_id=123"


def test_fragment_and_trailing_slash_are_stripped():
    assert normalise_url("https://s.com/p/1/#reviews") == "https://s.com/p/1"
    assert normalise_url("https://s.com/p/1/") == "https://s.com/p/1"


def test_normalising_is_idempotent():
    once = normalise_url("https://s.com/p/1/?utm_source=g#x")
    assert normalise_url(once) == once


def test_card_never_carries_a_price_or_stock_level():
    """Even if a stale column somehow held one, it must not reach a visitor."""
    card = to_card({"product_key": "k", "name": "X", "description": None,
                    "price": "129.00", "currency": "AUD", "availability": "in_stock",
                    "image_url": None, "product_url": "https://s.com/p",
                    "category": None, "ctas": []})
    assert "price" not in card
    assert "currency" not in card
    assert "availability" not in card


def test_search_maps_knowledge_base_hits_onto_products_in_order():
    """Retrieval order is the ranking; the first URL matched is the best match."""
    hits = [{"metadata": {"source_url": "https://s.com/p/2"}},
            {"metadata": {"source_url": "https://s.com/p/1"}},
            {"metadata": {"source_url": "https://s.com/p/2"}}]
    rows = [{"product_url": "https://s.com/p/1", "product_key": "k1"},
            {"product_url": "https://s.com/p/2", "product_key": "k2"}]

    with patch.object(mod, "get_embeddings", return_value=[0.0]), \
         patch.object(mod, "search_vector_data", return_value=hits), \
         patch.object(mod, "find_by_urls", return_value=rows):
        out = mod.search_products("org_x", "something light for jogging")

    assert [r["product_key"] for r in out] == ["k2", "k1"], "duplicates collapse, order holds"


def test_search_returns_nothing_rather_than_raising_when_retrieval_fails():
    with patch.object(mod, "get_embeddings", side_effect=RuntimeError("azure down")):
        assert mod.search_products("org_x", "shoes") == []


def test_related_products_follow_the_stored_order():
    found = [{"product_key": "b"}, {"product_key": "a"}]
    with patch.object(mod, "find_by_keys", return_value=found):
        out = mod.related_products("org_x", {"related_keys": ["a", "b"]})
    assert [r["product_key"] for r in out] == ["a", "b"]


def test_a_product_with_no_related_keys_returns_nothing():
    assert mod.related_products("org_x", {"related_keys": []}) == []
    assert mod.related_products("org_x", {}) == []


def test_card_never_leaks_internal_fields():
    card = to_card({"product_key": "k", "name": "X", "description": None,
                    "image_url": None, "product_url": "https://s.com/p",
                    "category": None, "ctas": []})
    assert "product_key" not in card
    assert set(card) == {"product_id", "name", "description", "image_url",
                         "url", "ctas", "options"}


def test_embed_batch_splits_into_batches_of_64_and_preserves_order():
    texts = [f"text-{i}" for i in range(130)]

    def _respond(input, model, timeout):
        response = MagicMock()
        response.usage.prompt_tokens = 1
        response.usage.total_tokens = 1
        response.data = [MagicMock(embedding=[t]) for t in input]
        return response

    with patch.object(mod.client, "embeddings") as embeddings:
        embeddings.create.side_effect = _respond
        vectors = mod._embed_batch(texts)

    assert embeddings.create.call_count == 3
    assert vectors == [[t] for t in texts]


def test_relatedness_caps_a_large_catalogue_and_logs_what_was_skipped(caplog):
    """A catalogue past the cap must not turn into thousands of embedding
    calls; the skipped count must be logged, not swallowed."""
    total = MAX_RELATED_PRODUCTS + 7
    rows = [{"product_key": f"k{i}", "name": f"P{i}", "description": ""}
            for i in range(total)]
    vectors = [[float(i)] for i in range(MAX_RELATED_PRODUCTS)]
    saved = []

    with patch.object(mod, "_query", return_value=rows), \
         patch.object(mod, "_embed_batch", return_value=vectors), \
         patch.object(mod, "save_related", side_effect=lambda t, k, r: saved.append(k)), \
         caplog.at_level("WARNING"):
        updated = mod.compute_relatedness("org_x")

    assert updated == MAX_RELATED_PRODUCTS
    assert len(saved) == MAX_RELATED_PRODUCTS
    assert any("skip" in message.lower() for message in caplog.messages)


def test_find_by_url_with_none_returns_empty_dict_instead_of_raising():
    conn = MagicMock()
    cur = conn.cursor.return_value.__enter__.return_value
    cur.fetchone.return_value = [False]  # no products table for this tenant
    with patch.object(mod, "get_db_connection", return_value=conn):
        assert mod.find_by_url("org_x", None) == {}


def test_relatedness_returns_zero_and_leaves_related_keys_untouched_when_embedding_fails():
    rows = [{"product_key": "a", "name": "A", "description": ""},
            {"product_key": "b", "name": "B", "description": ""}]
    with patch.object(mod, "_query", return_value=rows), \
         patch.object(mod, "_embed_batch", return_value=[]), \
         patch.object(mod, "save_related") as save_related:
        updated = mod.compute_relatedness("org_x")

    assert updated == 0
    save_related.assert_not_called()
