Subversion Repositories SmartDukaan

Rev

Blame | Last modification | View Log | RSS feed

package com.smartdukaan.cron.offercircular;

import java.util.ArrayList;
import java.util.Arrays;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

/**
 * Splitting and normalising the "Eligible products" cell.
 *
 * This is the most error-prone part of the circular. Three behaviours here are
 * load-bearing and were each arrived at by fixing a real mis-parse:
 *
 *  1. Never split on '/' blindly - it appears inside variant specs, so
 *     "Tab S11 Ultra Wifi (12/256GB)/Tab S11 Ultra Wifi (12/512GB)" would produce a
 *     phantom product literally called "256GB". Parenthesised groups are masked
 *     first and '/' between two digits is never a split point.
 *  2. Capacities canonicalise to <n>X (GB) and <n>Y (TB), never <n>G, so the network
 *     marker in "Reno 16C 5G" cannot be mistaken for 5 GB of storage.
 *  3. Model keys collapse spaces but keep digits significant, so "RENO16C" equals
 *     "Reno 16C 5G" while "iPhone 17e" never equals "iPhone 7".
 */
public final class ProductNames {

    private ProductNames() { }

    /** Brand names and the 5G/4G suffix are noise - the circular and the catalog
     *  disagree about whether to include them. WIFI/LTE/BT are NOT noise: for
     *  tablets and watches they are the only thing separating two real variants. */
    private static final Set<String> NOISE = new LinkedHashSet<>(Arrays.asList(
            "5G", "4G", "GALAXY", "TECNO", "ONEPLUS", "XIAOMI", "SAMSUNG", "APPLE",
            "REALME", "VIVO", "OPPO", "MOTOROLA", "MOTO", "NOTHING", "GOOGLE",
            "NEW", "WITH", "THE"));

    private static final char MASK = '\u0001';
    private static final char SLASH_KEEP = '\u0002';

    private static final Pattern PARENS = Pattern.compile("\\([^)]*\\)");
    private static final Pattern DIGIT_SLASH_DIGIT = Pattern.compile("(?<=\\d)\\s*/\\s*(?=\\d)");
    private static final Pattern MASK_THEN_LETTER =
            Pattern.compile("(" + MASK + "\\d+" + MASK + ")(?=[A-Za-z])");
    /** Two variant groups run straight together, e.g. "Edge 70 Pro (8+256)(12+256)".
     *  These are two variants of one model, not one product with four memory numbers.
     *  Left joined, the tokens merged and the offer bound to a single arbitrary SKU
     *  while the other variant silently got nothing - the same money-lands-on-the-
     *  wrong-SKU failure as the missing-unit bug. Only ADJACENT groups qualify: a '/'
     *  between them, as in "X300 FE(12+512G)/(12+256G)", is already a split point. */
    private static final Pattern MASK_THEN_MASK =
            Pattern.compile("(" + MASK + "\\d+" + MASK + ")(?=" + MASK + ")");
    private static final Pattern SPLIT = Pattern.compile(
            "\\s*(?:,|/|&(?!\\s*(?:ENCO|BUBBLE|ACCESS|EXT))|\\bAND\\b)\\s*",
            Pattern.CASE_INSENSITIVE);
    private static final Pattern VARIANT_ONLY =
            Pattern.compile("^[\\d\\s+/]*(?:GB|TB|G|T)?[\\d\\s+/GBT]*$", Pattern.CASE_INSENSITIVE);
    private static final Pattern STORE_PROSE = Pattern.compile(
            "\\(?\\s*(offer applicable on all stores except|only for)\\b.*$",
            Pattern.CASE_INSENSITIVE);
    private static final Pattern TRAILING_PAIR = Pattern.compile(
            "\\s*\\(?\\s*\\d+\\s*[+/]\\s*\\d+\\s*(?:GB|TB|G|T)?\\s*\\)?\\s*$",
            Pattern.CASE_INSENSITIVE);
    private static final Pattern TRAILING_SINGLE = Pattern.compile(
            "\\s*\\(?\\s*\\d+\\s*(?:GB|TB)\\s*\\)?\\s*$", Pattern.CASE_INSENSITIVE);
    private static final Pattern HAS_LETTER = Pattern.compile("[A-Za-z]");

    /*
     * A '+'-joined accessory bundle - "RENO16C 8+256GB +Bubble",
     * "X300 Pro(16+512G)+Extender" - is deliberately NOT stripped down to the phone.
     *
     * Stripping it looks tempting: it would resolve 30 CATALOG_GAP rows and light up
     * ten otherwise-dark offers. It was tried and reverted, because on Aug'26 vivo pays
     * a HIGHER cap for the bundle than for the bare phone - X300 Pro(16+512G) is capped
     * at Rs.10,000 on its own row and Rs.11,000 on the "+Extender" row, the difference
     * being the Extender itself. Merging them lets the bundle's cap be claimed on a
     * phone sold without the accessory. (Oppo's bundles happen to carry identical
     * values, which is what made the merge look safe until vivo was checked.)
     *
     * Whether a bundle offer transfers to the bare SKU is a commercial question the PDF
     * does not answer, so it belongs in manual curation as a coverage decision, not in
     * the parser.
     */

    private static final Pattern GB = Pattern.compile("(\\d+)\\s*GB", Pattern.CASE_INSENSITIVE);
    private static final Pattern TB = Pattern.compile("(\\d+)\\s*TB", Pattern.CASE_INSENSITIVE);
    /** "a+b" / "a/b" is ALWAYS RAM + storage, with or without a unit on either side. */
    private static final Pattern MEMORY_PAIR = Pattern.compile(
            "\\b(\\d+)\\s*(?:GB|G)?\\s*[+/]\\s*(\\d+)\\s*(GB|TB|G|T)?", Pattern.CASE_INSENSITIVE);
    /** Leading \b is essential: without it the "5X" inside the model name "A5X"
     *  reads as 5 GB, which both invents a phantom capacity and reduces the model key
     *  to "A" - making Oppo A5X and A6X indistinguishable. */
    private static final Pattern CAPACITY = Pattern.compile("\\b\\d+[XY]\\b");
    /** Same \b requirement as MEMORY_PAIR and CAPACITY: a digit glued to letters
     *  belongs to the model name. Without it "S25+ 5G" had its "25+ 5" stripped as a
     *  pair, keying Samsung S25+ as "SG". */
    private static final Pattern PAIR_ANY = Pattern.compile(
            "\\(?\\s*\\b\\d+\\s*[XY]?\\s*[+/]\\s*\\d+\\s*[XY]?\\s*\\)?");
    private static final Pattern NON_ALNUM = Pattern.compile("[^A-Z0-9 ]");

    /**
     * Loose normalisation used for alias keys and for fuzzy similarity.
     *
     * Deliberately NOT the same as the capacity canonicalisation used by modelKey:
     * here GB/TB collapse to G/T, which is fine because the result is only ever
     * compared against another string put through the same function.
     */
    public static String norm(String text) {
        String s = text.toUpperCase().replace('+', ' ').replace('/', ' ');
        s = GB.matcher(s).replaceAll("$1G");
        s = TB.matcher(s).replaceAll("$1T");
        s = NON_ALNUM.matcher(s).replaceAll(" ");
        return s.trim().replaceAll("\\s+", " ");
    }

    /** Splits a multi-product cell, inheriting the model name for variant-only parts. */
    public static List<String> split(String cell) {
        List<String> out = new ArrayList<>();
        if (cell == null || cell.trim().isEmpty()) {
            return out;
        }
        List<String> masks = new ArrayList<>();
        StringBuffer masked = new StringBuffer();
        Matcher paren = PARENS.matcher(cell);
        while (paren.find()) {
            masks.add(paren.group());
            paren.appendReplacement(masked,
                    Matcher.quoteReplacement(MASK + String.valueOf(masks.size() - 1) + MASK));
        }
        paren.appendTail(masked);

        String text = DIGIT_SLASH_DIGIT.matcher(masked).replaceAll(String.valueOf(SLASH_KEEP));
        // two products run together with no delimiter, e.g.
        // "A37 5G (8GB/128GB)A37 5G (12GB/256GB)" - break after a masked group
        text = MASK_THEN_LETTER.matcher(text).replaceAll("$1,");
        // ...and two variant groups run together, "Edge 70 Pro (8+256)(12+256)"
        text = MASK_THEN_MASK.matcher(text).replaceAll("$1,");

        String lastModel = null;
        for (String part : SPLIT.split(text)) {
            String p = part.replace(SLASH_KEEP, '/');
            for (int i = 0; i < masks.size(); i++) {
                p = p.replace(MASK + String.valueOf(i) + MASK, masks.get(i));
            }
            p = STORE_PROSE.matcher(p).replaceAll("");
            p = strip(p, " .");
            if (p.isEmpty() || "NA".equalsIgnoreCase(p) || "N/A".equalsIgnoreCase(p)
                    || "-".equals(p)) {
                continue;
            }
            String bare = strip(p, "() ");
            if (VARIANT_ONLY.matcher(bare).matches() && lastModel != null) {
                out.add(lastModel + " " + bare);       // "RENO16C 12+256GB, 8+256GB"
                continue;
            }
            out.add(p);
            // remember the model WITHOUT its variant, so the next bare "8+256GB" or
            // "(512GB)" attaches to the model rather than to a full variant string
            String base = TRAILING_PAIR.matcher(p).replaceAll("");
            base = strip(TRAILING_SINGLE.matcher(base).replaceAll(""), " .(");
            if (!base.isEmpty() && HAS_LETTER.matcher(base).find()) {
                lastModel = base;
            }
        }
        return out;
    }

    /**
     * Capacities become &lt;n&gt;X (GB) / &lt;n&gt;Y (TB) so the network marker in
     * "Reno 16C 5G" can never read as 5 GB of storage.
     *
     * The OEMs use three notations for the same thing and ALL must be recognised,
     * because whether memory was mentioned decides variant-level versus model-level
     * matching:
     *   Oppo      "12+256GB"   explicit unit
     *   vivo      "(8+256G)"    single-letter unit
     *   Motorola  "(8+256)"     no unit at all
     * Missing any of these makes the matcher believe no size was given and bind the
     * offer to an arbitrary sibling - which put a ₹2,000 Edge 60 Pro 12+256 offer and
     * a ₹1,000 8+256 offer on the same SKU.
     *
     * A PAIR is unambiguous: "a+b" or "a/b" is always RAM + storage, whatever the
     * unit, so both numbers are capacities. A STANDALONE number is only a capacity
     * when it carries an explicit GB/TB - a bare "5G" or "4G" is a network marker.
     *
     * The leading \b is essential. Without it "S25+ 5G" matches as the pair "25 + 5",
     * eating the 25 out of the model name S25 and the 5 out of the network marker 5G,
     * which keyed S25+ as "S25X". A digit glued to letters is part of the name, never
     * a capacity.
     */
    private static String canonUnits(String text) {
        String s = text.toUpperCase();

        // pairs first: the second number's unit decides TB vs GB, the first is RAM in GB
        Matcher pair = MEMORY_PAIR.matcher(s);
        StringBuffer out = new StringBuffer();
        while (pair.find()) {
            String unit = pair.group(3);
            String storageUnit = (unit != null && unit.startsWith("T")) ? "Y" : "X";
            String replacement = pair.group(1) + "X " + pair.group(2) + storageUnit;
            pair.appendReplacement(out, Matcher.quoteReplacement(replacement));
        }
        pair.appendTail(out);
        s = out.toString();

        // then standalone capacities, which require an explicit unit
        s = GB.matcher(s).replaceAll("$1X");
        s = TB.matcher(s).replaceAll("$1Y");
        return s;
    }

    /** RAM/storage tokens, order-insensitive: "12+256GB" -&gt; {12X, 256X} */
    public static Set<String> variantTokens(String text) {
        Set<String> tokens = new LinkedHashSet<>();
        Matcher m = CAPACITY.matcher(canonUnits(text));
        while (m.find()) {
            tokens.add(m.group());
        }
        return tokens;
    }

    private static String stripVariant(String text) {
        String s = canonUnits(text);
        s = PAIR_ANY.matcher(s).replaceAll(" ");
        s = CAPACITY.matcher(s).replaceAll(" ");
        return s;
    }

    /**
     * Spaces are insignificant, digits are significant. "RENO16C" and "Reno 16C 5G"
     * both key to RENO16C; "iPhone 17e" keys to IPHONE17E and can never collide with
     * "iPhone 7" (IPHONE7).
     */
    public static String modelKey(String text) {
        // A '+' surviving this far is part of the MODEL NAME, not a memory separator:
        // canonUnits has already consumed "8+256" into capacity tokens. It must be
        // preserved as a word, because "Realme 16 Pro" and "Realme 16 Pro+" are
        // different phones at different prices - stripping it as punctuation collapsed
        // them to the same key and cross-matched 6 SKUs across 12 offers.
        String s = NON_ALNUM.matcher(stripVariant(text).replace("+", " PLUS "))
                .replaceAll(" ");
        StringBuilder key = new StringBuilder();
        for (String word : s.trim().split("\\s+")) {
            if (!word.isEmpty() && !NOISE.contains(word)) {
                key.append(word);
            }
        }
        return key.toString();
    }

    /** Python's str.strip(chars) - trims any of the given characters from both ends. */
    private static String strip(String s, String chars) {
        int from = 0;
        int to = s.length();
        while (from < to && chars.indexOf(s.charAt(from)) >= 0) { from++; }
        while (to > from && chars.indexOf(s.charAt(to - 1)) >= 0) { to--; }
        return s.substring(from, to);
    }
}