"""The line key, the attribute vocabulary and the form, pinned on the rows that decided them.

Rian's decision of 12 Sep (one page per real product) turned the 1 Million diagnosis into
a rule set: the fourteen 1 Million rows (ids 7761 to 7805, 14747, 14766, 14767, 17216 to
17219, 17314, 17885 on the 11 Sep staging copy) must land as one brand, one line, three
attribute_values, and the set kept apart. Every other case here is a real spelling from the same
copy that a looser rule joined wrongly or a stricter one split.
"""

import pytest

from app.services import proposal_rules
from app.services.product_lines import (
    ATTRIBUTE_KINDS as ATTRIBUTE_KINDS_FOR_TEST,
    brand_words_of,
    canonical_attribute,
    display_line_name,
    display_attribute,
    form_of,
    product_line_key,
    line_slug,
    attribute_of,
)


def proposed(name, **kwargs):
    """The residual name every grouping list makes together (identity rules v6): the identity
    rules v5 line key, so the old list-deletion tests read as what those lists now propose."""
    return proposal_rules.read_line(name, rules=frozenset(proposal_rules.GROUPING_RULES), **kwargs)

# (brand as collected, name as collected, quantity_ml) for the fourteen 1 Million rows.
ONE_MILLION = [
    ("Paco Rabanne", "1 Million Parfum 5cl", 50),
    ("Paco Rabanne", "1 Million Parfum 10cl", 100),
    ("Paco Rabanne", "1 Million 5cl", 50),
    ("Paco Rabanne", "1 Million 10cl", 100),
    ("Paco Rabanne", "1 Million 20cl", 200),
    ("Rabanne", "Rabanne 1 Million EDT 100ml", 100),
    ("Rabanne", "Rabanne 1 Million Elixir Parfum Intense 50ml", 50),
    ("Rabanne", "Rabanne 1 Million Elixir Parfum Intense 100ml", 100),
    ("Rabanne", "1 Million Elixir Parfum Intense 200 ml", 200),
    ("Rabanne", "1 Million Eau de Toilette 100 ml", 100),
    ("Rabanne", "1 Million Eau de Toilette 200 ml", 200),
    ("Rabanne", "1 Million Eau de Toilette 50 ml", 50),
    ("Rabanne", "1 Million Elixir Eau de Parfum Intense 200 ml", 200),
    ("Rabanne", "Rabanne Set: Duo 1 Million Eau de Toilette 50 ml 100 ml", 50),
]


class TestOneMillion:
    def test_one_line_under_one_house(self):
        """Identity rules v6: the fold across all fourteen spellings is what every grouping list
        proposes together; the certain key alone keeps a format word ("Set: Duo") the v5 list
        used to delete, so it is not one value across all fourteen rows by itself."""
        proposed_keys = {proposed(name, listed_brand=listed_brand, brand="Rabanne", vertical="beauty").key for listed_brand, name, _ in ONE_MILLION}
        assert proposed_keys == {"1 million"}
        certain_keys = {product_line_key(name, listed_brand=listed_brand, brand="Rabanne", vertical="beauty") for listed_brand, name, _ in ONE_MILLION}
        assert certain_keys == {"1 million", "set duo 1 million"}
        for key in certain_keys:
            assert set(key.split()) >= {"1", "million"}

    def test_three_variations_and_the_rows_that_say_nothing(self):
        canon = [attribute_of(name, "beauty")[1] for _, name, _ in ONE_MILLION]
        assert set(canon) == {"parfum", "", "edt", "elixir"}
        assert canon[:5] == ["parfum", "parfum", "", "", ""]
        assert canon[5] == "edt" and canon[6:9] == ["elixir"] * 3 and canon[9:12] == ["edt"] * 3
        assert canon[12] == "elixir", "Elixir Eau de Parfum Intense is the Elixir, not an EDP"

    def test_the_raw_wording_is_kept_beside_the_canonical(self):
        assert attribute_of("1 Million Elixir Parfum Intense 200 ml", "beauty") == ("elixir parfum intense", "elixir")
        assert attribute_of("1 Million Elixir Eau de Parfum Intense 200 ml", "beauty") == ("elixir edp intense", "elixir")

    def test_the_set_is_kept_apart(self):
        forms = [form_of(name) for _, name, _ in ONE_MILLION]
        assert forms[-1] == "set" and set(forms[:-1]) == {""}


class TestBeautyLines:
    def test_audience_is_part_of_the_line_and_its_spellings_agree(self):
        """Identity rules v6: the audience fold is now the `audience` list's proposal; the
        certain key keeps the shop's own spelling ("for Men", "pour Homme", "Male") instead of
        folding it, so two spellings of the same audience are no longer one certain key by
        themselves; only the connector words (a pure deletion) still leave the certain key
        the way an old spelling that was already canonical did."""
        men = proposed("Eternity For Men Eau de Toilette 100 ml", brand="Calvin Klein", vertical="beauty").key
        assert men == "eternity men"
        assert proposed("Calvin Klein Eternity Men EDT 100ml", brand="Calvin Klein", vertical="beauty").key == men
        assert proposed("Eternity pour Homme", brand="Calvin Klein", vertical="beauty").key == men
        assert proposed("Light Blue Capri In Love Male", brand="Dolce & Gabbana", vertical="beauty").key == \
            proposed("Light Blue Capri In Love Pour Homme", brand="Dolce & Gabbana", vertical="beauty").key
        assert proposed("Eternity for Her Eau de Parfum 100 ml", brand="Calvin Klein", vertical="beauty").key == "eternity women"
        assert proposed("Calvin Klein Eternity Women EDP 100ml", brand="Calvin Klein", vertical="beauty").key == "eternity women"

        assert product_line_key("Eternity For Men Eau de Toilette 100 ml", brand="Calvin Klein", vertical="beauty") == "eternity for men"
        assert product_line_key("Calvin Klein Eternity Men EDT 100ml", brand="Calvin Klein", vertical="beauty") == "eternity men"
        assert product_line_key("Eternity pour Homme", brand="Calvin Klein", vertical="beauty") == "eternity pour homme"
        assert product_line_key("Light Blue Capri In Love Male", brand="Dolce & Gabbana", vertical="beauty") == "light blue capri in love male"
        assert product_line_key("Light Blue Capri In Love Pour Homme", brand="Dolce & Gabbana", vertical="beauty") == "light blue capri in love pour homme"
        assert product_line_key("Eternity for Her Eau de Parfum 100 ml", brand="Calvin Klein", vertical="beauty") == "eternity for her"
        assert product_line_key("Calvin Klein Eternity Women EDP 100ml", brand="Calvin Klein", vertical="beauty") == "eternity women"

        homme_reading = proposal_rules.read_line("Eternity pour Homme", brand="Calvin Klein", vertical="beauty",
                                                  rules=frozenset(proposal_rules.GROUPING_RULES))
        assert homme_reading.removed["audience"] == ["homme"] and homme_reading.removed["connectors"] == ["pour"]

    def test_a_bare_number_is_the_line_not_a_size(self):
        assert product_line_key("Cheirosa '62 Body Mist 90 ml", brand="Sol de Janeiro", vertical="beauty") == "cheirosa 62"
        assert product_line_key("Cheirosa 76 Perfume Mist", brand="Sol De Janeiro", vertical="beauty") == "cheirosa 76"
        assert product_line_key("N°5 L'EAU 100ml", brand="CHANEL", vertical="beauty") == "no 5 leau"
        assert product_line_key("Chanel No. 5 Eau de Parfum 100ml", brand="Chanel", vertical="beauty") == "no 5"

    def test_brand_words_leave_the_line_but_never_empty_it(self):
        """Identity rules v6: "by" and "la" are now the `connectors` list's proposal; the
        certain key keeps them."""
        assert proposed("Flower by Kenzo Eau de Parfum 100 ml", brand="Kenzo", vertical="beauty").key == "flower"
        assert product_line_key("Flower by Kenzo Eau de Parfum 100 ml", brand="Kenzo", vertical="beauty") == "flower by"
        assert product_line_key("Kenzo Flower", brand="Kenzo", vertical="beauty") == "flower"
        assert proposed("Lancôme La Vie Est Belle", brand="Lancôme", vertical="beauty").key == "vie est belle"
        assert product_line_key("Lancôme La Vie Est Belle", brand="Lancôme", vertical="beauty") == "la vie est belle"
        assert product_line_key("Nina Ricci Ricci Ricci", brand="Nina Ricci", vertical="beauty") == "ricci ricci"
        assert product_line_key("Nina Ricci Nina 5cl", brand="Nina Ricci", vertical="beauty") == "nina"

        by_reading = proposal_rules.read_line("Flower by Kenzo Eau de Parfum 100 ml", brand="Kenzo", vertical="beauty",
                                               rules=frozenset(proposal_rules.GROUPING_RULES))
        assert by_reading.removed["connectors"] == ["by"]

    def test_the_house_name_is_stripped_too_when_the_brand_text_is_an_alias(self):
        assert product_line_key("Rabanne Invictus Aqua 100ml Eau de Toilette", listed_brand="Paco Rabanne", brand="Rabanne", vertical="beauty") == "invictus aqua"
        assert product_line_key("Invictus Aqua", listed_brand="Paco Rabanne", brand="Rabanne", vertical="beauty") == "invictus aqua"

    def test_format_and_edition_words_leave_the_line(self):
        """Identity rules v6: the format, edition and "Travel Exclusive" packaging words are now
        `format_words`/`noise` proposals; the certain key keeps them."""
        assert proposed("Sauvage Eau de Toilette Spray 100ml Travel Exclusive", brand="Dior", vertical="beauty").key == "sauvage"
        assert product_line_key("Sauvage Eau de Toilette Spray 100ml Travel Exclusive", brand="Dior", vertical="beauty") == "sauvage spray travel exclusive"
        assert proposed("Fame Refill 200ml", brand="Rabanne", vertical="beauty").key == "fame"
        assert product_line_key("Fame Refill 200ml", brand="Rabanne", vertical="beauty") == "fame refill"
        assert proposed("FAME Couture limited-edition 80ml", brand="Rabanne", vertical="beauty").key == "fame couture"
        assert product_line_key("FAME Couture limited-edition 80ml", brand="Rabanne", vertical="beauty") == "fame couture limited edition"

        refill_reading = proposal_rules.read_line("Fame Refill 200ml", brand="Rabanne", vertical="beauty",
                                                   rules=frozenset(proposal_rules.GROUPING_RULES))
        assert refill_reading.removed["format_words"] == ["refill"]
        edition_reading = proposal_rules.read_line("FAME Couture limited-edition 80ml", brand="Rabanne", vertical="beauty",
                                                    rules=frozenset(proposal_rules.GROUPING_RULES))
        assert edition_reading.removed["format_words"] == ["limited", "edition"]

    def test_flankers_the_vocabulary_does_not_know_stay_in_the_line(self):
        """"Aromatic Essence" is not a attribute word: the join is a person's, in the queue.
        Identity rules v6: "for" is now the `connectors` list's proposal; the certain key
        keeps it."""
        assert proposed("Eternity Aromatic Essence for Men EDP Intense 100ml", brand="Calvin Klein", vertical="beauty").key == "eternity aromatic essence men"
        assert product_line_key("Eternity Aromatic Essence for Men EDP Intense 100ml", brand="Calvin Klein", vertical="beauty") == "eternity aromatic essence for men"
        assert product_line_key("Euphoria Bold Elixir Parfum Intense 100ml", brand="Calvin Klein", vertical="beauty") == "euphoria bold"


class TestDrinkLines:
    @pytest.mark.parametrize(("brand", "name", "key", "certain"), [
        ("1800", "1800 Anejo Triple Cask 40% 1L", "anejo triple cask", "anejo triple cask"),
        ("Grant's", "Grant's Triple Wood 1L", "triple wood", "triple wood"),
        ("Grant's", "Grant's Triple Wood 12 Years Old 1L", "triple wood 12", "triple wood 12yo"),
        ("Jameson", "Jameson Triple Triple 40% 1L", "triple triple", "triple triple"),
        ("The Balvenie", "The Balvenie Triple Cask Whisky 12 Years Old 1L", "triple cask 12", "triple cask whisky 12yo"),
        ("Johnnie Walker", "Johnnie Walker Black Label Triple Cask Blended Scotch Whisky Travel Exclusive 1L",
         "black label triple cask", "black label triple cask blended scotch whisky travel exclusive"),
        ("Artisian", "Artisian Triple Oak VSOP Cognac 1L", "triple oak vsop", "triple oak vsop cognac"),
        ("Jameson", "Jameson Triple Distilled Irish Whiskey 1L", "triple distilled", "triple distilled irish whiskey"),
    ])
    def test_a_pack_word_that_is_the_expression_stays(self, brand, name, key, certain):
        """Identity rules v5 (the decisions doc §2.5). Under v4 every pack word left every
        line, so "1800 Anejo Triple Cask" keyed as `anejo cask` and "Macallan Triple Cask 12"
        as `cask 12`, where a plain "Cask 12" would have folded into it: 272 live product variants
        carried one of the words, and every drink with form single among the twelve sampled
        was an expression. Identity rules v6: the drink, region and age words that used to
        leave the line are now `drink_words`/`region_words`/`age_words` proposals; the certain
        key keeps them, and "triple" itself is never removed either way; the pack-word reading
        still calls a single-form name the expression."""
        assert proposed(name, brand=brand, vertical="liquor").key == key
        assert product_line_key(name, brand=brand, vertical="liquor") == certain
        assert proposal_rules.pack_word_reading(name) == ("expression", "form: single")
        assert "triple" in key.split() and "triple" in certain.split()

    @pytest.mark.parametrize(("brand", "name", "key", "certain", "pack_reading"), [
        ("Cutty Sark", "Cutty Sark Blended Whisky Triple Pack 3x1L", "", "blended whisky triple pack",
         ("packaging", "form: pack")),
        ("Chivas Regal", "Chivas Regal 12 Years Old Triple Pack 3x1L", "12", "12yo triple pack",
         ("packaging", "form: pack")),
        ("Absolut", "Absolut Twin Pack 2x1L", "", "twin pack", ("packaging", "form: pack")),
        ("Bellevoye", "Bellevoye Prune, Whisky triple malt 70cl", "prune", "prune whisky triple malt",
         ("expression", "form: single")),
        ("Hennessy", "Hennessy VS Gift Pack 70cl", "vs", "vs gift pack", None),
        ("Sol", "Sol 12 Bottle Pack 330ml", "12", "12 bottle pack", None),
    ])
    def test_a_pack_word_that_is_packaging_or_a_category_word_goes(self, brand, name, key, certain, pack_reading):
        """A name the quantity parser reads as a pack or a set keeps the v4 behaviour; "triple
        malt" describes the category rather than naming the expression; and "pack" itself is
        always packaging, even where the parser missed the pack ("12 Bottle Pack"). Identity
        rules v6: every one of these is now a proposal (`pack_words`, `format_words` or
        `noise`); the certain key keeps every word. Hennessy and Sol carry no "triple"/"tri"/
        "twin" at all, so `pack_word_reading` never enters into removing "Gift Pack" or "Bottle
        Pack" there; those are plain noise/format words."""
        assert proposed(name, brand=brand, vertical="liquor").key == key
        assert product_line_key(name, brand=brand, vertical="liquor") == certain
        if pack_reading:
            assert proposal_rules.pack_word_reading(name) == pack_reading
            pack_word = "triple" if "triple" in name.lower() else "twin"
            reading = proposal_rules.read_line(name, brand=brand, vertical="liquor",
                                                rules=frozenset(proposal_rules.GROUPING_RULES))
            assert reading.removed["pack_words"] == [pack_word]
            assert pack_word in certain.split()

    def test_the_expression_words_are_kept_in_beauty_too(self):
        """Seven beauty rows on the 15 Sep copy carried "triple" or "tri" as a single item and
        none was a pack: the rule is the same in every vertical. Identity rules v6: "duo" is now
        a `format_words` proposal; the certain key keeps it."""
        assert product_line_key("Rénergie H.C.F. Triple Serum 50ml", brand="Lancôme", vertical="beauty") == "renergie hcf triple serum"
        assert product_line_key("Black Pepper Triple Candle 600g", brand="Molton Brown", vertical="beauty") == "black pepper triple candle"
        assert proposed("Beauty Elixir Duo", brand="Caudalie", vertical="beauty").key == "beauty"
        assert product_line_key("Beauty Elixir Duo", brand="Caudalie", vertical="beauty") == "beauty duo"

    def test_the_display_name_keeps_the_expression_too(self):
        assert display_line_name("triple cask 12", ["The Balvenie Triple Cask Whisky 12 Years Old 1L"], "The Balvenie",
                                 brand_words=brand_words_of("The Balvenie"), vertical="liquor") == "Triple Cask 12"

    @pytest.mark.parametrize(("name", "certain"), [
        ("Chivas Regal 12 Year Old Blended Scotch Whisky 1L", "12yo blended scotch whisky"),
        ("Chivas Regal 12 Years Old 1L", "12yo"),
        ("Chivas Regal 12YO 40% 0.5L", "12yo"),
        ("Chivas Regal 12y 40% 1L", "12yo"),
        ("Chivas Regal 12y 40% 2x1L", "12yo"),
        ("Chivas Regal 12 yo Blended Scotch Whisky 1L", "12yo blended scotch whisky"),
        ("Chivas Regal 12 Years Old Triple Pack 3x1L", "12yo triple pack"),
        ("12 Year Old Blended Scotch Whisky Scotland 1L", "12yo blended scotch whisky scotland"),
    ])
    def test_every_spelling_of_an_age_is_one_line(self, name, certain):
        """Identity rules v6: every spelling still folds to one age TOKEN in the certain key
        ("12yo"), that half of the age rule is certain, not a proposal, but the certain key
        keeps the drink, region and format words the v5 `age_words`/`drink_words`/`pack_words`
        lists used to delete on top of it."""
        assert proposed(name, brand="Chivas Regal", vertical="liquor").key == "12"
        assert product_line_key(name, brand="Chivas Regal", vertical="liquor") == certain

    def test_a_12_and_an_18_are_different_lines(self):
        assert proposed("Glenfiddich 12 Year Old Highland Single Malt 750ml", brand="Glenfiddich", vertical="liquor").key == "12"
        assert product_line_key("Glenfiddich 12 Year Old Highland Single Malt 750ml", brand="Glenfiddich", vertical="liquor") == "12yo highland single malt"
        assert proposed("Glenfiddich 18 Year Old Port Cask Finish 70cl", brand="Glenfiddich", vertical="liquor").key == "18 port cask finish"
        assert product_line_key("Glenfiddich 18 Year Old Port Cask Finish 70cl", brand="Glenfiddich", vertical="liquor") == "18yo port cask finish"
        assert product_line_key("Glenfiddich 18 Years Old Perpetual Collection Vat 04 70cl", brand="Glenfiddich", vertical="liquor") != "18"

    def test_expression_words_stay_and_category_words_go(self):
        """Identity rules v6: the category (drink) words are now `drink_words` proposals; the
        certain key keeps them."""
        assert proposed("Absolut Original Vodka Sweden 50cl", brand="Absolut", vertical="liquor").key == "original"
        assert product_line_key("Absolut Original Vodka Sweden 50cl", brand="Absolut", vertical="liquor") == "original vodka sweden"
        assert proposed("Absolut Vodka 1L", brand="Absolut", vertical="liquor").key == ""
        assert product_line_key("Absolut Vodka 1L", brand="Absolut", vertical="liquor") == "vodka"
        assert proposed("Bombay Sapphire London Dry Gin 1L", brand="Bombay Sapphire", vertical="liquor").key == ""
        assert product_line_key("Bombay Sapphire London Dry Gin 1L", brand="Bombay Sapphire", vertical="liquor") == "london dry gin"
        assert proposed("Bombay Sapphire Gin 1L", brand="Bombay Sapphire", vertical="liquor").key == ""
        assert product_line_key("Bombay Sapphire Gin 1L", brand="Bombay Sapphire", vertical="liquor") == "gin"
        assert product_line_key("Johnnie Walker Blue Label 1L", brand="Johnnie Walker", vertical="liquor") == "blue label"
        assert proposed("Hennessy XO Cognac 70cl", brand="Hennessy", vertical="liquor").key == "xo"
        assert product_line_key("Hennessy XO Cognac 70cl", brand="Hennessy", vertical="liquor") == "xo cognac"
        assert product_line_key("Hennessy X.O 70cl", brand="Hennessy", vertical="liquor") == "xo"
        assert product_line_key("Hennessy V.S.O.P. Privilège 1L", brand="Hennessy", vertical="liquor") == "vsop privilege"
        assert product_line_key("Hennessy VSOP 1L", brand="Hennessy", vertical="liquor") == "vsop"

    def test_a_cask_keeps_the_category_word_that_names_it(self):
        """Identity rules v6: everywhere except the cask word itself, the drink and age words
        that used to leave are now proposals (`drink_words`, `age_words`), including the
        brand's own repeated word ("Chivas"), which is now `brand_partial`'s proposal."""
        assert proposed("Chivas Extra 13 Rum Cask Selection Blended Whisky 1L", brand="Chivas Regal", vertical="liquor").key == "extra 13 rum cask selection"
        assert product_line_key("Chivas Extra 13 Rum Cask Selection Blended Whisky 1L", brand="Chivas Regal", vertical="liquor") == "chivas extra 13 rum cask selection blended whisky"
        assert proposed("Chivas Regal Extra 13 Years Old Blended Scotch Whisky Sherry Cask Selection 1000 ml", brand="Chivas Regal", vertical="liquor").key == "extra 13 sherry cask selection"
        assert product_line_key("Chivas Regal Extra 13 Years Old Blended Scotch Whisky Sherry Cask Selection 1000 ml", brand="Chivas Regal", vertical="liquor") == "extra 13yo blended scotch whisky sherry cask selection"
        assert proposed("Glenfiddich 18 Year Old Port Cask Finish 70cl", brand="Glenfiddich", vertical="liquor").key == "18 port cask finish"
        assert product_line_key("Glenfiddich 18 Year Old Port Cask Finish 70cl", brand="Glenfiddich", vertical="liquor") == "18yo port cask finish"

    def test_only_the_brands_last_word_leaves_the_middle_of_a_name(self):
        """The second "paris" in PSG's "paris" cap tells it from the plain cap. Identity rules
        v6: dropping a brand's last word alone is now `brand_partial`'s proposal and "de" is a
        `connectors` proposal; the certain key keeps every word of a brand it did not match
        whole, and keeps "de" too."""
        psg = "Paris Saint-Germain - PSG"
        assert product_line_key('Paris saint-germain - "paris" cap', brand=psg) != product_line_key("Paris saint germain - cap", brand=psg)
        cap_reading = proposal_rules.read_line('Paris saint-germain - "paris" cap', brand=psg,
                                                rules=frozenset(proposal_rules.GROUPING_RULES))
        assert cap_reading.key == "paris cap"
        assert cap_reading.removed["brand_partial"] == ["saint", "germain", "paris"]
        assert product_line_key('Paris saint-germain - "paris" cap', brand=psg) == "paris saint germain paris cap"
        assert proposed("Bleu de Chanel Parfum 100ml", brand="Chanel", vertical="beauty").key == "bleu"
        assert product_line_key("Bleu de Chanel Parfum 100ml", brand="Chanel", vertical="beauty") == "bleu de"

    def test_a_drink_never_loses_its_concentration_words(self):
        """Talisker's "Eau de Vie" is not a fragrance; drinks strip no juice words. Identity
        rules v6: "de" is now a `connectors` proposal; the certain key keeps it."""
        assert proposed("Talisker 10 Eau de Vie 70cl", brand="Talisker", vertical="liquor").key == "10 eau vie"
        assert product_line_key("Talisker 10 Eau de Vie 70cl", brand="Talisker", vertical="liquor") == "10 eau de vie"

    def test_packaging_shorthand_goes(self):
        """Identity rules v6: the packaging shorthand words are now `format_words`/`age_words`
        proposals; the certain key keeps them."""
        assert proposed("Chivas Regal 18YO W.C.48% 1L G", brand="Chivas Regal", vertical="liquor").key == "18 wc"
        assert product_line_key("Chivas Regal 18YO W.C.48% 1L G", brand="Chivas Regal", vertical="liquor") == "18yo wc g"
        assert proposed("Glenfiddich 30 Year Old 70cl", brand="Glenfiddich", vertical="liquor").key == "30"
        assert product_line_key("Glenfiddich 30 Year Old 70cl", brand="Glenfiddich", vertical="liquor") == "30yo"
        assert proposed("Glenfid 25yo 43% 0.7L GP", brand="Glenfiddich", vertical="liquor").key == "glenfid 25"
        assert product_line_key("Glenfid 25yo 43% 0.7L GP", brand="Glenfiddich", vertical="liquor") == "glenfid 25yo gp"


class TestVariation:
    def test_drinks_have_none(self):
        assert attribute_of("Talisker 10 Eau de Vie 70cl", "liquor") == ("", "")
        assert attribute_of("Sauvage Eau de Parfum", None) == ("", "")

    @pytest.mark.parametrize(("name", "raw", "canonical"), [
        ("J'adore Eau de Parfum 10cl", "edp", "edp"),
        ("Polo Red Eau de Toilette 125 ml", "edt", "edt"),
        ("Dior Homme Cologne 12.5cl", "edc", "edc"),
        ("Cheirosa '76 Perfume Mist 90 ml", "parfum mist", "parfum"),
        ("Burberry Goddess Parfum 100 ml", "parfum", "parfum"),
        ("Invictus Intense Eau de Toilette 100 ml", "intense edt", "edt intense"),
        ("Bleu de Chanel Parfum", "parfum", "parfum"),
        ("Sauvage 100ml", "", ""),
        ("Absolue Rich Cream Refill 60ml", "absolu", "absolu"),
        ("Gucci Guilty Absolu de Parfum Pour Homme 60ml", "absolu", "absolu"),
        ("Gucci Guilty Elixir de Parfum Pour Homme 60 ml", "elixir", "elixir"),
    ])
    def test_the_words_found_and_what_they_mean(self, name, raw, canonical):
        assert attribute_of(name, "beauty") == (raw, canonical)

    def test_display_wording(self):
        assert display_attribute("edt intense") == "Eau de Toilette Intense"
        assert display_attribute("elixir") == "Elixir"
        assert canonical_attribute("edp intense elixir") == "elixir"


class TestForm:
    @pytest.mark.parametrize(("name", "form"), [
        ("Good Girl Gift Set 80ml + 10ml", "set"), ("Bottled Gift Set", "set"), ("Boss Bottled 10cl", ""),
        ("Fame Refill 200ml", "refill"), ("Absolue Rich Cream Refill 60ml", "refill"),
        ("Chivas Regal 12y 40% 2x1L", "pack"), ("Absolut Twin Pack 2x1L With Free Absolut Raspberri 375ml", "pack"),
        ("1 Million 10cl", ""),
    ])
    def test_sets_refills_and_packs_never_share_a_row_with_the_bottle(self, name, form):
        assert form_of(name) == form


class TestDisplay:
    def test_the_most_common_spelling_gives_the_name_as_the_shop_wrote_it(self):
        """Identity rules v6: a caller now passes the CERTAIN key, not the old folded one; the
        candidate display text is checked by folding it back through `product_line_key`, which
        no longer folds "for men" to "men", so the hardcoded old key would only ever fall back
        to a title-cased echo of itself."""
        from collections import Counter
        samples = Counter({"1 Million Eau de Toilette 100 ml": 5, "1 million 10cl": 1})
        assert display_line_name("1 million", samples, "Rabanne", vertical="beauty") == "1 Million"
        ck = brand_words_of("Calvin Klein")
        eternity_key = product_line_key("Eternity For Men Eau de Toilette 100 ml", brand="Calvin Klein", vertical="beauty")
        assert eternity_key == "eternity for men"
        assert display_line_name(eternity_key, ["Eternity For Men Eau de Toilette 100 ml"], "Calvin Klein", brand_words=ck, vertical="beauty") == "Eternity For Men"
        assert display_line_name("", ["Absolut Vodka 1L"], "Absolut") == "Absolut"
        assert display_line_name("xo", ["Hennessy XO Cognac 70cl"], "Hennessy", brand_words=brand_words_of("Hennessy"), vertical="liquor") == "XO"
        assert display_line_name("blue label", [], "Johnnie Walker") == "Blue Label"
        assert display_line_name("15", ["Dewar's 15y 40% 1L"], "Dewar's", brand_words=brand_words_of("Dewar's"), vertical="liquor") == "15"

    def test_articles_apostrophes_and_accents_survive_in_the_name(self):
        """The key folds them so shops agree; the name a person reads keeps them. Identity rules
        v6: the old fold (`key`) is now what the connectors/audience lists propose together; the
        certain key keeps the connector or the shop's own audience spelling, and the display is
        built from THAT certain key: for every sample here it folds back to the same words the
        old, hand-picked `expected` display already had, except "Flower by Kenzo", where the
        certain key keeps "by" and the display keeps it too."""
        cases = [
            ("men", "Jean Paul Gaultier Le Male EDT 125ml", "Jean Paul Gaultier", "Le Male"),
            ("vie est belle", "Lancôme La Vie Est Belle Eau de Parfum 50 ml", "Lancôme", "La Vie Est Belle"),
            ("linterdit", "L'Interdit Eau de Parfum 80ml", "Givenchy", "L'Interdit"),
            ("terre dhermes", "Terre d'Hermès Parfum 75ml", "HERMÈS", "Terre d'Hermès"),
            ("women", "Narciso Rodriguez For Her Eau de Parfum 100 ml", "Narciso Rodriguez", "For Her"),
            ("men", "Dior Homme Parfum 100ml", "Dior", "Homme"),
            ("re nutriv ultimate lift cream", "Estée Lauder Re-Nutriv Ultimate Lift Cream 50ml", "Estée Lauder", "Re-Nutriv Ultimate Lift Cream"),
            ("flower", "Flower by Kenzo Eau de Parfum 100 ml", "Kenzo", "Flower by"),
            ("gentleman society", "Givenchy Gentleman Society Extrême Eau de Parfum 100ml", "Givenchy", "Gentleman Society"),
            ("guilty men", "Gucci Guilty Absolu de Parfum Pour Homme 60ml", "Gucci", "Guilty Pour Homme"),
            ("guilty men", "Gucci Guilty Elixir de Parfum Pour Homme 60 ml", "Gucci", "Guilty Pour Homme"),
            ("ricci ricci", "Nina Ricci Ricci Ricci EDP 80ml", "Nina Ricci", "Ricci Ricci"),
        ]
        for key, sample, brand, expected in cases:
            assert proposed(sample, brand=brand, vertical="beauty").key == key, sample
            certain = product_line_key(sample, brand=brand, vertical="beauty")
            assert display_line_name(certain, [sample], brand, brand_words=brand_words_of(brand), vertical="beauty") == expected, sample

    def test_slugs(self):
        assert line_slug("rabanne", "1 million") == "rabanne-1-million"
        assert line_slug("absolut", "") == "absolut"


class TestVariationKinds:
    """Stream L: a attribute carries a kind; "shade" is a shop word for the kind color, read
    for Makeup on the separator the Heinemann-family shops actually use (the text after the
    last " / "); the beauty branch of the line rules is untouched, so a confectionery flavor
    never routes a bar through it."""

    def test_the_kinds_and_the_two_meanings_are_decoupled(self):
        from app.services.product_lines import BEAUTY_VERTICALS, ATTRIBUTE_KINDS, ATTRIBUTE_RULES, ATTRIBUTE_VERTICALS, attribute_kind_of
        assert ATTRIBUTE_KINDS == ("concentration", "color", "flavor", "age", "cask", "edition")
        assert BEAUTY_VERTICALS == {"beauty"} and "confectionery" in ATTRIBUTE_VERTICALS
        assert set(ATTRIBUTE_RULES) == {("beauty", None), ("beauty", "Makeup"), ("confectionery", None)}
        assert attribute_kind_of("1 Million Parfum 5cl", "beauty") == "concentration"

    @pytest.mark.parametrize(("name", "canonical"), [
        ("Joli Blush Blusher 6 gr / 02 Cheeky Pink", "02 cheeky pink"),
        ("SYNCHRO SKIN Self-Refreshing Foundation 30 ml / 220 Linen", "220 linen"),
        ("SYNCHRO SKIN Self-Refreshing Foundation 30 ml / 230 Alder", "230 alder"),
        ("Colour Riche Lipstick 3.6 gr / 800 Fairest Nude", "800 fairest nude"),
        ("Foundation 30 ml / 110N", "110n"),
    ])
    def test_a_makeup_shade_is_a_color(self, name, canonical):
        """15139, 15329, 15330 on the 14 Sep copy."""
        from app.services.product_lines import attribute_kind_of
        assert attribute_of(name, "beauty", category="Makeup") == (canonical, canonical)
        assert attribute_kind_of(name, "beauty", category="Makeup") == "color"

    @pytest.mark.parametrize(("name", "category"), [
        ("Powder Brush N°13", "Makeup"), ("Chanel N°5 EDP", "Makeup"),
        ("Purifying Gentle Foaming Cleanser 125ml / Grasa", "Skincare"), ("1 Million Parfum 5cl", "Perfume"),
        ("Colour Riche Lipstick 3.6 gr / 800 Fairest Nude", None),
    ])
    def test_no_color_without_the_separator_or_outside_makeup(self, name, category):
        """11106, 15910; Skincare's " / Grasa" tails are skin types; the gate is the category."""
        from app.services.product_lines import attribute_kind_of
        assert attribute_kind_of(name, "beauty", category=category) != "color"

    @pytest.mark.parametrize(("name", "key"), [
        ("Colour Riche Lipstick 3.6 gr / 800 Fairest Nude", "colour riche lipstick"),
        ("Colour Riche Lipstick 3.6 gr / 801 Nude Rose", "colour riche lipstick"),
        ("SYNCHRO SKIN Self-Refreshing Foundation 30 ml / 220 Linen", "synchro skin self refreshing foundation"),
        ("SYNCHRO SKIN Self-Refreshing Foundation 30 ml / 230 Alder", "synchro skin self refreshing foundation"),
    ])
    def test_the_shade_leaves_the_makeup_line(self, name, key):
        """Identity rules v5 (rian, 15 Sep: "shades are attribute_values of a product line, not new
        product lines"). Under v4 the shade stayed in the key, so CHANEL Rouge Allure was 59
        lines, Lancome Teint Idole 45 and Shiseido Synchro Skin 43, one per shade, and 88
        percent of all lines held a single product. The shade is still the product's attribute
        (kind color), so two shades never fold into one product."""
        assert product_line_key(name, brand="L'Oréal", vertical="beauty", category="Makeup") == key
        assert attribute_of(name, "beauty", category="Makeup")[1] != ""

    def test_the_tail_leaves_across_the_beauty_vertical_a_skin_type_included(self):
        """The rule is the vertical's, not the category's: 384 of the 783 tailed beauty rows on
        the 15 Sep copy had no category (every CHANEL Rouge Allure row among them), so a rule
        gated on Makeup left them split. Identity rules v6: the marked " / " tail is now a
        CERTAIN removal for every beauty name (the collector's own option field, or this reader
        as its fallback), a skin type included, unlike the v5 word list that kept "/ Seca" in
        the line by name. `proposal_rules.read_skin_type` still names the closed vocabulary, for
        whatever reads the tail's meaning afterwards."""
        assert product_line_key("ROUGE ALLURE 3.5gr / 199 INATTENDU", brand="CHANEL", vertical="beauty") == "rouge allure"
        assert attribute_of("ROUGE ALLURE 3.5gr / 199 INATTENDU", "beauty") == ("199 inattendu", "199 inattendu")
        from app.services.product_lines import attribute_kind_of
        assert attribute_kind_of("ROUGE ALLURE 3.5gr / 199 INATTENDU", "beauty") == ""
        assert attribute_kind_of("ROUGE ALLURE 3.5gr / 199 INATTENDU", "beauty", category="Makeup") == "color"
        assert product_line_key("Purifying Gentle Foaming Cleanser 125ml / Grasa", brand="Clarins", vertical="beauty", category="Skincare") == "purifying gentle foaming cleanser"
        assert attribute_of("Purifying Gentle Foaming Cleanser 125ml / Grasa", "beauty", category="Skincare") == ("grasa", "grasa")
        # A shade the classifier filed under Skincare collapses all the same: the rule is the vertical's.
        assert product_line_key("Tinted Moisturizer 30 ml / 02 Light", brand="Clarins", vertical="beauty", category="Skincare") == "tinted moisturizer"
        assert attribute_of("Tinted Moisturizer 30 ml / 02 Light", "beauty", category="Skincare") == ("02 light", "02 light")
        assert product_line_key("Hydra Essentiel Cream 50 ml / Todo tipo de piel", brand="Clarins", vertical="beauty") == "hydra essentiel cream"
        assert proposal_rules.read_skin_type("seca") == "seca"
        assert proposal_rules.read_skin_type("99 pirate") is None
        # A drink never reads a tail: the vertical has no tail rule, so "Gift Box" stays in the
        # certain key; "irish"/"whiskey"/"gift box" are now drink_words/region_words/noise proposals.
        assert proposed("Jameson Irish Whiskey 1L / Gift Box", brand="Jameson", vertical="liquor").key == ""
        assert product_line_key("Jameson Irish Whiskey 1L / Gift Box", brand="Jameson", vertical="liquor") == "irish whiskey gift box"
        assert attribute_of("Jameson Irish Whiskey 1L / Gift Box", "liquor") == ("", "")

    def test_a_display_name_folds_back_to_the_shadeless_key(self):
        samples = ["Colour Riche Lipstick 3.6 gr / 800 Fairest Nude", "Colour Riche Lipstick 3.6 gr / 801 Nude Rose"]
        assert display_line_name("colour riche lipstick", samples, "L'Oréal", brand_words=brand_words_of("L'Oréal"),
                                 vertical="beauty", category="Makeup") == "Colour Riche Lipstick"

    def test_a_confectionery_flavor_is_read_only_from_a_marked_tail(self):
        """Identity rules v5 (the decisions doc §2.6). The v4 reader tagged any flavour word
        in a name: 158 product variants carried the kind, 156 of them Paris one-offs like "15 Fine
        Chocolates Almond Crispy", one comparable product among them, and the one marked tail
        was a lipstick whose shade is "praline nude". A flavour is what the shop marked."""
        from app.services.product_lines import attribute_kind_of
        assert attribute_of("Dark Chocolate 70%", "confectionery") == ("", "")
        assert attribute_kind_of("Milk Chocolate", "confectionery") == ""
        assert attribute_of("15 Fine Chocolates Almond Crispy", "confectionery") == ("", "")
        assert attribute_of("Lindor Truffles 200 g / Milk", "confectionery") == ("milk", "milk")
        assert attribute_kind_of("Lindor Truffles 200 g / Milk", "confectionery") == "flavor"
        assert attribute_of("Excellence 100g", "confectionery") == ("", "")
        # A flavour word in the name is the product's own name and stays in the line.
        assert product_line_key("Lindt Excellence Dark Chocolate 70% 100g", brand="Lindt", vertical="confectionery") == "excellence dark chocolate"
        assert product_line_key("Lindt Lindor Milk Chocolate Truffles 200g", brand="Lindt", vertical="confectionery") == "lindor milk chocolate truffles"
        # A marked flavour leaves the line, as a shade does.
        assert product_line_key("Lindt Lindor Truffles 200 g / Milk", brand="Lindt", vertical="confectionery") == "lindor truffles"
        assert product_line_key("Lindt Lindor Truffles 200 g / Dark", brand="Lindt", vertical="confectionery") == "lindor truffles"

    def test_every_kind_a_reader_produces_has_a_behavior(self):
        """The decisions doc §2.4: a picked attribute regenerates the comparison; a shown one
        is information and never picked. Only a kind a reader produces has one: age, cask
        and edition have no reader (a drink keeps its age in the line), so the question never
        arises, and a marked variant of unknown kind is shown until something names it."""
        from app.services.product_lines import SHOWN, PICKED, ATTRIBUTE_DISPLAY, ATTRIBUTE_RULES, display_of
        produced = {kind for rules in ATTRIBUTE_RULES.values() for kind, _ in rules if kind}
        assert set(ATTRIBUTE_DISPLAY) == produced == {"concentration", "color", "flavor"}
        assert set(ATTRIBUTE_DISPLAY) <= set(ATTRIBUTE_KINDS_FOR_TEST)
        assert display_of("concentration") == PICKED
        assert display_of("color", vertical="beauty", category="Makeup") == SHOWN
        assert display_of("flavor", vertical="confectionery") == SHOWN
        assert display_of("age") == "" and display_of("") == "" and display_of("nonsense") == ""

    def test_the_one_million_fixture_keeps_its_three_concentrations(self):
        canon = {attribute_of(name, "beauty", category="Perfume")[1] for _, name, _ in ONE_MILLION}
        assert canon == {"parfum", "", "edt", "elixir"}

    def test_gram_packs_and_counts_leave_the_line(self):
        assert product_line_key("Macarons 12 x 20g", brand="Ladurée", vertical="confectionery") == "macarons"
        assert product_line_key("Biscuits X20 Pieces", brand="Walkers", vertical="confectionery") == "biscuits"
        assert product_line_key("Advanced Ceramide Capsules 90 caps", brand="Elizabeth Arden", vertical="beauty") == "advanced ceramide capsules"
