import pytest

from app.services.pairing.rules import (
    PAIR_TYPES, attribute_jaccard, score_complement, score_similar, score_upsell,
)


def p(key, category="phones", accessory=False, price=50000, rating=4.0,
      tier="mid", attrs=None):
    return {"product_key": key, "name": key, "category": category,
            "is_accessory": accessory, "price_cents": price, "rating": rating,
            "price_tier": tier,
            "attributes": attrs if attrs is not None else [
                {"key": "color", "value": "black"},
                {"key": "material", "value": "glass"}]}


def test_the_three_types():
    # "bundle" was built and removed: it assembled a set rather than describing a
    # relationship, and the content signal here cannot assemble one.
    assert PAIR_TYPES == ("similar", "complement", "upsell")


# --- attribute overlap -------------------------------------------------------

def test_identical_attributes_overlap_fully():
    assert attribute_jaccard(p("a"), p("b")) == 1.0


def test_disjoint_attributes_do_not_overlap():
    other = p("b", attrs=[{"key": "color", "value": "red"},
                          {"key": "material", "value": "cloth"}])
    assert attribute_jaccard(p("a"), other) == 0.0


def test_overlap_is_symmetric():
    a, b = p("a"), p("b", attrs=[{"key": "color", "value": "black"}])
    assert attribute_jaccard(a, b) == attribute_jaccard(b, a)


def test_no_attributes_means_no_overlap_not_a_crash():
    assert attribute_jaccard(p("a", attrs=[]), p("b", attrs=[])) == 0.0


# --- similar -----------------------------------------------------------------

def test_same_category_close_price_is_similar():
    assert score_similar(p("a"), p("b"), cosine=0.9) is not None


def test_a_different_category_is_never_similar():
    # A phone case is not a substitute for a phone, however alike the text.
    assert score_similar(p("a", "phones"), p("b", "cases"), cosine=0.99) is None


def test_a_wildly_different_price_is_not_similar():
    assert score_similar(p("a", price=1000), p("b", price=500000),
                         cosine=0.95) is None


def test_low_text_similarity_is_not_similar():
    assert score_similar(p("a"), p("b"), cosine=0.1) is None


def test_similar_carries_reasons_a_merchant_can_read():
    result = score_similar(p("a"), p("b"), cosine=0.9)
    assert result["reasons"]
    assert all(isinstance(r, str) for r in result["reasons"])


# --- complement --------------------------------------------------------------

def test_a_phone_suggests_a_case():
    anchor, case = p("phone", "phones"), p("case", "cases", accessory=True)
    assert score_complement(anchor, case, cosine=0.4) is not None


def test_a_case_does_not_suggest_a_phone():
    # The asymmetry is the entire point of is_accessory.
    anchor, case = p("phone", "phones"), p("case", "cases", accessory=True)
    assert score_complement(case, anchor, cosine=0.4) is None


def test_an_unrelated_accessory_is_not_a_complement():
    # is_accessory says a thing IS an accessory, not what it is an accessory
    # FOR. A phone must not complement a spatula just because a spatula is an
    # accessory in a different category.
    phone = p("phone", "smartphones")
    spatula = p("spatula", "kitchen-accessories", accessory=True)
    assert score_complement(phone, spatula, cosine=0.05) is None


def test_a_related_accessory_outranks_a_barely_related_one():
    phone = p("phone", "smartphones")
    case = p("case", "mobile-accessories", accessory=True)
    close = score_complement(phone, case, cosine=0.45)
    far = score_complement(phone, case, cosine=0.25)
    assert close["score"] > far["score"]


def test_matching_colour_alone_is_not_a_complement():
    # A black laptop does not complement black bread. Without a text-similarity
    # floor, any two same-coloured products in different categories paired up.
    laptop = p("laptop", "laptops", attrs=[{"key": "color", "value": "black"}])
    bread = p("bread", "groceries", attrs=[{"key": "color", "value": "black"}])
    assert score_complement(laptop, bread, cosine=0.05) is None


def test_two_accessories_are_not_complements():
    a = p("case", "cases", accessory=True)
    b = p("cable", "cables", accessory=True)
    assert score_complement(a, b, cosine=0.5) is None


def test_the_same_category_is_not_a_complement():
    a, b = p("phone1", "phones"), p("phone2", "phones", accessory=True)
    assert score_complement(a, b, cosine=0.5) is None


def test_an_accessory_complement_scores_above_a_weak_one():
    strong = score_complement(p("phone", "phones"),
                              p("case", "cases", accessory=True), cosine=0.4)
    weak = score_complement(p("shirt", "shirts"),
                            p("trousers", "trousers"), cosine=0.4)
    assert weak is None or strong["score"] > weak["score"]


def test_clashing_colours_score_below_matching_ones():
    # The clothing case: a white shirt goes with navy trousers, not orange.
    shirt = p("shirt", "shirts", attrs=[{"key": "color", "value": "white"}])
    navy = p("navy", "trousers", accessory=False,
             attrs=[{"key": "color", "value": "navy"}])
    orange = p("orange", "trousers", accessory=False,
               attrs=[{"key": "color", "value": "orange"}])
    good = score_complement(shirt, navy, cosine=0.4)
    bad = score_complement(shirt, orange, cosine=0.4)
    if good and bad:
        assert good["score"] > bad["score"]


# --- upsell ------------------------------------------------------------------

def test_a_pricier_equally_rated_product_is_an_upsell():
    assert score_upsell(p("a", price=50000, rating=4.0),
                        p("b", price=90000, rating=4.2)) is not None


def test_a_cheaper_product_is_never_an_upsell():
    assert score_upsell(p("a", price=90000), p("b", price=50000)) is None


def test_a_barely_pricier_product_is_not_an_upsell():
    # A 2% difference is noise, not a trade-up.
    assert score_upsell(p("a", price=50000), p("b", price=51000)) is None


def test_a_worse_rated_product_is_never_an_upsell():
    assert score_upsell(p("a", price=50000, rating=4.5),
                        p("b", price=90000, rating=2.0)) is None


def test_an_upsell_across_categories_is_not_an_upsell():
    assert score_upsell(p("a", "phones", price=50000),
                        p("b", "laptops", price=90000)) is None


def test_upsell_is_arithmetic_and_therefore_high_confidence():
    result = score_upsell(p("a", price=50000), p("b", price=90000))
    assert result["source"] == "arithmetic"
    assert result["confidence"] >= 0.9


def test_a_missing_price_cannot_be_an_upsell():
    assert score_upsell(p("a", price=None), p("b", price=90000)) is None


def test_a_missing_rating_does_not_block_an_upsell():
    # 88% of the live catalog has a rating; the rest must still be pairable.
    assert score_upsell(p("a", price=50000, rating=None),
                        p("b", price=90000, rating=None)) is not None


# --- gender and accessory targeting ------------------------------------------

def gendered(key, category, gender, accessory=False, targets=None):
    attrs = [{"key": "gender", "value": gender}]
    for target in targets or []:
        attrs.append({"key": "accessory_for", "value": target})
    return {"product_key": key, "name": key, "category": category,
            "is_accessory": accessory, "price_cents": 5000, "rating": 4.0,
            "attributes": attrs}


def test_a_mens_shirt_does_not_suggest_a_womens_handbag():
    # Gender was extracted from the very first enrichment run and nothing read
    # it, so a men's shirt recommended a women's purse as its top complement.
    shirt = gendered("shirt", "mens-shirts", "male")
    handbag = gendered("handbag", "womens-bags", "female", accessory=True,
                       targets=["dresses", "tops"])
    assert score_complement(shirt, handbag, cosine=0.44) is None


def test_a_unisex_accessory_still_complements_either_gender():
    # Only a definite disagreement blocks; unisex must stay compatible or most
    # legitimate accessories disappear.
    shirt = gendered("shirt", "mens-shirts", "male")
    scarf = gendered("scarf", "scarves", "unisex", accessory=True,
                     targets=["shirts"])
    assert score_complement(shirt, scarf, cosine=0.4) is not None


def test_an_unknown_gender_does_not_block():
    shirt = gendered("shirt", "mens-shirts", "male")
    belt = gendered("belt", "belts", None, accessory=True, targets=["shirts"])
    assert score_complement(shirt, belt, cosine=0.4) is not None


def test_matching_genders_are_fine():
    shirt = gendered("shirt", "mens-shirts", "male")
    watch = gendered("watch", "mens-watches", "male", accessory=True,
                     targets=["shirts"])
    assert score_complement(shirt, watch, cosine=0.4) is not None


def test_an_accessory_only_complements_what_it_is_for():
    # is_accessory is a boolean, so without a target a basketball hoop and a
    # pair of earbuds both "complement" a shirt.
    shirt = gendered("shirt", "mens-shirts", "male")
    earbuds = gendered("earbuds", "mobile-accessories", "unisex",
                       accessory=True, targets=["phones"])
    assert score_complement(shirt, earbuds, cosine=0.4) is None


def test_target_matching_tolerates_plurals_and_prefixes():
    # The model writes "shirts" where the category is "mens-shirts".
    shirt = gendered("shirt", "mens-shirts", "male")
    belt = gendered("belt", "belts", "male", accessory=True, targets=["shirt"])
    assert score_complement(shirt, belt, cosine=0.4) is not None


def test_an_accessory_with_no_target_still_complements():
    # A catalogue not yet re-enriched must not lose every complement it had.
    shirt = gendered("shirt", "mens-shirts", "male")
    belt = gendered("belt", "belts", "male", accessory=True, targets=[])
    assert score_complement(shirt, belt, cosine=0.4) is not None
