import copy

import pytest

from app.services.catalog.normalise import (
    content_hash, normalise, quality_score, record_hash,
)

CONTEXT = {
    "shop_name": "Galaxiq",
    "primary_domain": "https://galaxiq-braexaal.myshopify.com",
    "url_template": None,
    "currency": "INR",
    "reference_currency": "INR",
    "known_brands": [],
}


def sp(**kw):
    base = {
        "external_id": "gid://shopify/Product/1",
        "title": "The Minimal Snowboard",
        "description": None,
        "brand": "Galaxiq",
        "handle": "the-minimal-snowboard",
        "product_url": None,
        "image_url": None,
        "raw_category": None,
        "product_type": "snowboard",
        "tags": ["Sport", "Winter"],
        "collections": [],
        "status": "ACTIVE",
        "tracks_inventory": True,
        "variants": [{"price": "885.95", "compare_at_price": None,
                      "available": True, "options": {"Title": "Default Title"}}],
        "attributes_raw": [],
        "source_kind": "shopify",
        "source_ref": "galaxiq-braexaal.myshopify.com",
        "tenant_relations": {},
        "rating": None,
        "review_count": None,
        "featured_rank": None,
    }
    base.update(kw)
    return base


def test_happy_path():
    out = normalise(sp(), CONTEXT)
    assert out["product_key"] == \
        "shopify:galaxiq-braexaal.myshopify.com:gid://shopify/Product/1"
    assert out["name"] == "The Minimal Snowboard"
    assert out["price_cents"] == 88595
    assert out["price_max_cents"] == 88595
    assert out["currency"] == "INR"
    assert out["in_stock"] is True
    assert out["status"] == "ACTIVE"
    assert out["taxonomy_path"] == ["snowboard"]
    assert out["taxonomy_source"] == "product_type"
    assert out["product_url"] == \
        "https://galaxiq-braexaal.myshopify.com/products/the-minimal-snowboard"


def test_shop_name_brand_is_nulled():
    assert normalise(sp(), CONTEXT)["brand"] is None


def test_title_placeholder_option_produces_no_attribute():
    assert normalise(sp(), CONTEXT)["attributes"] == []


@pytest.mark.parametrize("bad", [
    {"external_id": None},
    {"title": None},
    {"title": "ab"},
    {"variants": []},
])
def test_validation_rejects(bad):
    assert normalise(sp(**bad), CONTEXT) is None


def test_negative_price_rejected():
    assert normalise(sp(variants=[{"price": "-1.00", "available": True}]),
                     CONTEXT) is None


def test_missing_brand_and_category_degrade_rather_than_reject():
    out = normalise(sp(brand=None, product_type=None, tags=[]), CONTEXT)
    assert out is not None
    assert out["brand"] is None
    assert out["taxonomy_source"] == "none"


def test_normalising_twice_is_identical():
    a = normalise(sp(), CONTEXT)
    b = normalise(sp(), CONTEXT)
    assert a == b


def test_content_hash_is_stable_across_tag_order():
    # Unsorted hashing changes the hash every sync and re-embeds the whole
    # catalog nightly. This is the most expensive normalisation bug available.
    a = normalise(sp(tags=["Sport", "Winter"]), CONTEXT)
    b = normalise(sp(tags=["Winter", "Sport"]), CONTEXT)
    assert content_hash(a) == content_hash(b)


def test_content_hash_changes_when_matching_content_changes():
    a = normalise(sp(), CONTEXT)
    b = normalise(sp(title="A Different Snowboard"), CONTEXT)
    assert content_hash(a) != content_hash(b)


def test_price_change_does_not_change_content_hash():
    # A price change must update the row without triggering a re-embed.
    a = normalise(sp(), CONTEXT)
    b = normalise(sp(variants=[{"price": "999.00", "compare_at_price": None,
                                "available": True, "options": {}}]), CONTEXT)
    assert content_hash(a) == content_hash(b)
    assert record_hash(a) != record_hash(b)


def test_no_field_is_ever_an_empty_string():
    out = normalise(sp(description="   ", brand="  "), CONTEXT)
    assert all(v != "" for v in out.values())


def test_price_min_never_exceeds_max():
    out = normalise(sp(variants=[
        {"price": "100.00", "available": True, "options": {}},
        {"price": "10.00", "available": True, "options": {}},
    ]), CONTEXT)
    assert out["price_cents"] <= out["price_max_cents"]


def test_product_key_differs_across_source_refs_with_same_external_id():
    # /catalog/sync loops over every source of a tenant; two sources of the
    # same kind sharing an external_id must not collide on product_key.
    a = normalise(sp(source_ref="store-a.myshopify.com"), CONTEXT)
    b = normalise(sp(source_ref="store-b.myshopify.com"), CONTEXT)
    assert a["product_key"] != b["product_key"]


def test_quality_score_range():
    out = normalise(sp(), CONTEXT)
    assert 0.0 <= quality_score(out) <= 1.0


def test_quality_score_rewards_completeness():
    sparse = normalise(sp(brand=None, product_type=None, tags=[],
                          image_url=None, description=None), CONTEXT)
    rich = normalise(sp(brand="Snowboard Vendor", description="A good board.",
                        image_url="https://cdn.example.com/x.jpg",
                        raw_category="Sporting Goods > Snowboards",
                        tags=["color:Navy", "size:M"]), CONTEXT)
    assert quality_score(rich) > quality_score(sparse)


def test_description_no_longer_affects_content_hash():
    # The matching specification's embedding input excludes description, so a
    # description-only edit must not trigger a re-embed.
    a = normalise(sp(description="One description"), CONTEXT)
    b = normalise(sp(description="A completely different description"), CONTEXT)
    assert content_hash(a) == content_hash(b)
    assert record_hash(a) != record_hash(b)


def test_a_product_without_a_url_is_kept_and_flagged():
    # A URL is genuinely needed, but rejecting the product hides the problem:
    # the merchant sees an empty catalog and no reason for it.
    out = normalise(sp(handle=None, product_url=None), CONTEXT)
    assert out is not None
    assert out["product_url"] is None
    assert out["missing_fields"] == ["product_url"]


def test_a_product_with_a_url_has_no_missing_fields():
    assert normalise(sp(), CONTEXT)["missing_fields"] == []


def test_missing_fields_is_never_none():
    # Downstream reads it as a list without a guard.
    assert isinstance(normalise(sp(), CONTEXT)["missing_fields"], list)


def test_the_hard_rejections_still_reject():
    # These cannot be flagged and carried -- there is nothing usable left.
    for bad in ({"external_id": None}, {"title": None}, {"title": "ab"},
                {"variants": []}):
        assert normalise(sp(**bad), CONTEXT) is None


def test_relations_and_prominence_are_carried_through():
    out = normalise(sp(tenant_relations={"related": ["1"]}, rating=4.5,
                       review_count=120, featured_rank=3), CONTEXT)
    assert out["tenant_relations"] == {"related": ["1"]}
    assert out["rating"] == 4.5
    assert out["review_count"] == 120
    assert out["featured_rank"] == 3


def test_missing_relations_normalise_to_an_empty_object():
    assert normalise(sp(), CONTEXT)["tenant_relations"] == {}
