#!/usr/bin/env python3
"""INCI list -> the app's actives (docs/dispatch/AH decision 3). Deterministic, no model.

A line-for-line replica of app/lib/core/rules/inci_actives.dart. Both are held to
data/inci_actives_fixture.json (40 real lists with hand-checked sets): the Dart side in
app/test/inci_actives_test.dart, this side in scripts/test_product_index.py. Change one, change
the other, and re-run both.

    python3 scripts/inci_actives.py "Aqua, Glycerin, Niacinamide, Salicylic Acid"

How a list is read:
- Lowercased, accents folded (the same fold as ShelfIdentity), OBF's `_allergen_` underscores
  and `*` marks removed, and a short leading label ("Ingredients:", "INCI:") dropped.
- Split into entries on , ; and bullets. An entry of more than MAX_ENTRY_WORDS words, not
  counting anything in brackets, is prose (a claims line, a sentence) and is skipped: that is how
  "with retinyl complex" in a claims string does not count. So is an entry saying "free".
- Fewer than MIN_ENTRIES entries, or more prose entries than list entries, is not a readable
  INCI list (a sentence, or a scan that lost its commas), and gives None: unknown, not "no
  actives".
- Each entry is matched against the patterns below. No percentages: INCI lists do not give them,
  and H7 and S6 already fail safe on a missing pct.

Traps, each in the fixture:
- Salicylate esters are not BHA. Benzyl and methyl salicylate are fragrance; ethylhexyl
  salicylate (octisalate) is a UV filter. Only salicylic acid (and its LHA and betaine forms) is.
- Ethyl ascorbic acid is a derivative, not L-ascorbic acid.
- Titanium dioxide and zinc oxide are also opacifiers, colourants and skin protectants (a zinc
  repair cream is not a sunscreen). They count as a UV filter only with a stated strength or
  "nano", or within the first MINERAL_FILTER_POSITION entries, and never as a CI number. An
  organic filter counts anywhere. Missing a filter is the safe error: H1 then asks for a
  sunscreen; inventing one could satisfy H1 with a moisturizer.
- Glycolic acid in a rinse-off cleanser still counts: the product carries it.
"""
import re
import sys

MAX_ENTRY_WORDS = 6
MIN_ENTRIES = 3
MINERAL_FILTER_POSITION = 3

# The same fold as ShelfIdentity._folds (scripts/shelf_identity.py), plus the typographic marks
# INCI lists carry.
_FOLDS = {
    "à": "a", "á": "a", "â": "a", "ã": "a", "ä": "a", "å": "a", "æ": "ae",
    "ç": "c",
    "è": "e", "é": "e", "ê": "e", "ë": "e",
    "ì": "i", "í": "i", "î": "i", "ï": "i",
    "ñ": "n",
    "ò": "o", "ó": "o", "ô": "o", "õ": "o", "ö": "o", "ø": "o", "œ": "oe",
    "ù": "u", "ú": "u", "û": "u", "ü": "u",
    "ý": "y", "ÿ": "y",
    "ß": "ss",
}

_LABEL = re.compile(r"^\s*[a-z /()]{0,30}:\s*")
_SPLIT = re.compile(r"[,;•·\n]+|\.\s+")
_BRACKETS = re.compile(r"[\(\[][^\)\]]*[\)\]]")
_WORDS = re.compile(r"[a-z0-9]+(?:[-/][a-z0-9]+)*")
_FREE = re.compile(r"\bfree\b|\bsans\b|\bwithout\b")
_CI = re.compile(r"\bci\s*77")

# (active wire value, pattern). Order does not matter: an entry can carry more than one.
_PATTERNS = [
    ("retinoid", re.compile(
        r"\bretinol\b|\bretinal\b|\bretinaldehyde\b|\bretinyl (?:palmitate|acetate|propionate|linoleate|retinoate)\b"
        r"|\bhydroxypinacolone retinoate\b|\bretinoic acid(?![a-z])|\btretinoin\b")),
    ("adapalene", re.compile(r"\badapalene\b")),
    ("aha", re.compile(r"\bglycolic acid(?![a-z])|\blactic acid(?![a-z])|\bmandelic acid(?![a-z])")),
    ("bha", re.compile(r"\bsalicylic acid(?![a-z])|\bbetaine salicylate\b")),
    ("vitamin_c_deriv", re.compile(
        r"\bascorbyl\b|\bascorbate\b|\bethyl ascorbic acid(?![a-z])|\b3-o-ethyl ascorbic acid(?![a-z])")),
    ("benzoyl_peroxide", re.compile(r"\bbenzoyl peroxide\b")),
    ("niacinamide", re.compile(r"\bniacinamide\b|\bnicotinamide\b")),
    ("azelaic", re.compile(r"\bazelaic acid(?![a-z])")),
    ("copper_peptides", re.compile(r"\bcopper tripeptide-1\b")),
    ("hyaluronic", re.compile(r"\bhyaluronic acid(?![a-z])|\bhyaluronate\b")),
    ("ceramides", re.compile(r"\bceramides?\b")),
]

# L-ascorbic acid: "ascorbic acid" that is not the ethyl derivative.
_LAA = re.compile(r"\bascorbic acid(?![a-z])")
_LAA_NOT = re.compile(r"\bethyl ascorbic acid(?![a-z])")

# Numbered INCI peptides (palmitoyl tripeptide-1, acetyl hexapeptide-8, sh-oligopeptide-1) and
# SYN-AKE's dipeptide. Copper tripeptide-1 is copper_peptides only.
_PEPTIDE = re.compile(r"\b[a-z-]*peptide-\d+\b|\bdipeptide diaminobutyroyl benzylamide\b")

_ORGANIC_FILTERS = re.compile(
    r"\bavobenzone\b|\bbutyl methoxydibenzoylmethane\b|\boctinoxate\b|\bethylhexyl methoxycinnamate\b"
    r"|\boctisalate\b|\bethylhexyl salicylate\b|\bhomosalate\b|\boctocrylene\b|\boxybenzone\b|\bbenzophenone-3\b"
    r"|\bensulizole\b|\bphenylbenzimidazole sulfonic acid(?![a-z])|\bbemotrizinol\b"
    r"|\bbis-ethylhexyloxyphenol methoxyphenyl triazine\b|\bethylhexyl triazone\b"
    r"|\bdiethylamino hydroxybenzoyl hexyl benzoate\b|\bdrometrizole trisiloxane\b"
    r"|\bmethylene bis-benzotriazolyl tetramethylbutylphenol\b|\bterephthalylidene dicamphor sulfonic acid(?![a-z])"
    r"|\btris-biphenyl triazine\b|\bdiethylhexyl butamido triazone\b|\bpolysilicone-15\b"
    r"|\bisoamyl p-methoxycinnamate\b|\b4-methylbenzylidene camphor\b"
    r"|\bdisodium phenyl dibenzimidazole tetrasulfonate\b")
_MINERAL_FILTERS = re.compile(r"\bzinc oxide\b|\btitanium dioxide\b")
# A stated strength ("Zinc Oxide 20%", the US drug-facts form) or the EU "(nano)" marks a filter.
_AS_FILTER = re.compile(r"\d\s*%|\bnano\b")


def fold(s):
    return "".join(_FOLDS.get(ch, ch) for ch in s.lower())


def entries(text):
    """The list's entries, cleaned. Exposed for the tests."""
    t = fold(text).replace("_", " ").replace("*", " ")
    t = _LABEL.sub("", t, count=1)
    out = []
    for raw in _SPLIT.split(t):
        e = " ".join(raw.strip().rstrip(".").split())
        if e:
            out.append(e)
    return out


def _is_prose(entry):
    bare = _BRACKETS.sub(" ", entry)
    return len(_WORDS.findall(bare)) > MAX_ENTRY_WORDS or bool(_FREE.search(bare))


def parse(text):
    """The set of wire actives in an INCI list, or None when the text is not a readable list.

    None means unknown (actives_source 'none'); an empty set means the list was read and carries
    none of the app's actives (actives_source 'inci').
    """
    if not text or not text.strip():
        return None
    es = entries(text)
    if len(es) < MIN_ENTRIES:
        return None
    prose = [_is_prose(e) for e in es]
    # Mostly long entries means the separators were lost (a scan with no commas): the list cannot
    # be read, and saying "no actives" about it would be a claim we cannot make.
    if sum(prose) * 2 > len(es):
        return None
    found = set()
    for i, e in enumerate(es):
        if prose[i]:
            continue
        for wire, pattern in _PATTERNS:
            if pattern.search(e):
                found.add(wire)
        if _LAA.search(e) and not _LAA_NOT.search(e):
            found.add("vitamin_c_laa")
        if _PEPTIDE.search(e) and "copper" not in e:
            found.add("peptides")
        if _ORGANIC_FILTERS.search(e):
            found.add("sunscreen")
        elif _MINERAL_FILTERS.search(e) and not _CI.search(e) and (i < MINERAL_FILTER_POSITION or _AS_FILTER.search(e)):
            found.add("sunscreen")
    return found


if __name__ == "__main__":
    result = parse(" ".join(sys.argv[1:]))
    print("unknown" if result is None else sorted(result))
