# Постобробка карти 301 (ТЗ 4.8): перевірка коду моделі між джерелом і ціллю, вибір правила в конфліктах.
# Запуск: python3 check_model_codes.py [karta-301.csv] [perevirka-vruchnu.csv]
# Читає карту, зібрану build_redirect_map.py, і перезаписує обидва файли.
# Повторний запуск дає той самий результат: колонка «Перевірка коду моделі» перераховується щоразу.
# Логіка:
# 1. Для правила з готовою ціллю на товар порівнюються код моделі (артикул), модель (номер, римська цифра,
#    модифікатор pro, max, ultra і под.) і обсяг памʼяті. Чого з джерела немає в цілі, те дає «перевірити вручну».
#    Кольори, регіональні суфікси коду і службові слова в порівнянні участі не беруть.
# 2. Конфлікт (одне джерело, кілька цілей): лишається правило, ціль якого має той самий код моделі, що джерело.
# 3. Правило www (джерело «/» на головну) не переноситься: його замінює правило зони 4.8.7.
import csv, re, sys, collections

KARTA = sys.argv[1] if len(sys.argv) > 1 else 'karta-301.csv'
MANUAL = sys.argv[2] if len(sys.argv) > 2 else 'perevirka-vruchnu.csv'
COL = 'Перевірка коду моделі'
MARK = 'перевірити вручну'
NOTE = 'код моделі в цілі інший: перевірити вручну'
NOMOVE = 'не переносити: правило www замінює правило зони (ТЗ 4.8.7)'

ROMAN = {'ii', 'iii', 'iv', 'vi', 'vii', 'viii'}
MODS = {'pro', 'max', 'ultra', 'plus', 'mini', 'lite', 'slim', 'fe', 'se'}
# модифікатор, якого немає в джерелі, але є в цілі, рахується лише для цих (fe, slim і se бувають частиною назви серії)
MODS_BACK = {'pro', 'max', 'ultra', 'plus', 'mini', 'lite'}
# регіональні та пакувальні хвости коду: mfxh4ua-a, sm-a566bzkcek, dc-gh6ee
REGION = re.compile(r'^(ua|eu|ee|ek|ekd|euk|ce|ru|ll|zd|ch|za|kh|hn|ah|am|a|e|k|g|x|ww|global|cn|in|us|uk)+$')
NET = {'5g', '4g', 'lte'}


def toks(path):
    slug = path.strip('/').split('/')[-1].lower().replace('_', '')
    out = []
    for t in slug.split('-'):
        if not t:
            continue
        if t.startswith('kopiya'):
            t = t[6:]  # «kopiya<слаг>»: копія того самого товару
            if not t:
                continue
        if 'kopiya' in t:
            # склеєне джерело «...kopiya<інший товар>»: другий товар відкидаємо
            head = t.split('kopiya')[0]
            if head:
                out.append(head)
            break
        if t not in ('plus',) and has_digit(t):
            t = t.replace('plus', '')
        t = re.sub(r'^(\d+)v(\d+)$', r'\1in\2', t)  # 2v1 = 2in1
        if t in ('gb', 'tb') and out and out[-1].isdigit():
            out[-1] += t
            continue
        out.append(t)
    # артикул Garmin 010-02648-40 чи 010-02863-21-a1-ad: хвіст після другої частини це колір, модель задають перші дві
    for i in range(len(out) - 2):
        if re.fullmatch(r'0\d\d', out[i]) and re.fullmatch(r'\d{5}', out[i + 1]):
            j = i + 2
            while j < len(out) and re.fullmatch(r'\d{2,4}|[a-z0-9]{1,2}|\d{2}[a-z0-9]{1,4}', out[j]):
                j += 1
            out = out[:i + 2] + out[j:]
            break
    # стан вживаного «ideal-10-10» і хвіст дубля слага «...-5053c005-2»
    if out[-3:-2] == ['ideal'] and out[-2:] == ['10', '10']:
        out = out[:-2]
    if len(out) > 1 and re.fullmatch(r'\d', out[-1]) and len(out[-2]) >= 5 and has_digit(out[-2]) and re.search('[a-z]', out[-2]):
        out = out[:-1]
    return out


def windows(ts, n=4):
    out = set()
    for i in range(len(ts)):
        for k in range(1, n + 1):
            if i + k <= len(ts):
                out.add(''.join(ts[i:i + k]))
    return out


def has_digit(t):
    return any(c.isdigit() for c in t)


def found(i, st, tw):
    """токен джерела st[i] є в цілі: сам, у зʼєднанні з сусідами, або з регіональним хвостом"""
    for a in range(max(0, i - 3), i + 1):
        for b in range(i + 1, min(len(st), a + 4) + 1):
            w = ''.join(st[a:b])
            if w in tw:
                return True
            for v in tw:
                if len(v) >= 4 and len(w) >= 4 and has_digit(v):
                    if w.startswith(v) and REGION.match(w[len(v):]):
                        return True
                    if v.startswith(w) and REGION.match(v[len(w):]):
                        return True
    return False


STOR = ('8', '16', '32', '64', '128', '256', '512', '1024')
RAM = ('1', '2', '3', '4', '6', '8', '12', '16', '18', '24', '32', '36', '48', '64')


def gb(x):
    return int(x[:-2]) * (1024 if x.endswith('tb') else 1)


def mem(ts):
    """обсяг памʼяті: (ОЗП, накопичувач) з токенів 12-512gb, 12512gb, 16-1tb, 161tb, 8gb512gb, 24gb-1tb"""
    vals = []
    for i, t in enumerate(ts):
        m = re.search(r'(?:(\d+)gb)?(\d+)(gb|tb)$', t)
        if not m:
            continue
        r_, d, u = m.groups()
        if u == 'tb':
            sto, pre = d[-1] + 'tb', d[:-1]
        elif d in STOR:
            sto, pre = d + 'gb', ''
        else:
            sto, pre = d + 'gb', ''
            for s_ in STOR[::-1]:
                if d.endswith(s_) and d[:-len(s_)] in RAM:
                    sto, pre = s_ + 'gb', d[:-len(s_)]
                    break
        pre = r_ or pre
        if not pre and i and ts[i - 1] in RAM:
            pre = ts[i - 1]
        if pre:
            vals.append(pre + 'gb')
        vals.append(sto)
    if not vals:
        return None
    vals = sorted(set(vals), key=gb)
    sto = vals[-1]
    ram = vals[-2] if len(vals) > 1 else None
    return (ram, sto)


def is_code(t):
    """артикул: 5+ знаків з літерами і цифрами (mu123, a566bzkc, 5053c005) або 5+ цифр"""
    return len(t) >= 5 and has_digit(t) and not re.fullmatch(r'(\d+gb)?\d+(gb|tb|mm|mah|w|hz)', t)


def check(src, tgt):
    """повертає перелік розбіжностей; порожній, якщо модель і памʼять збігаються"""
    st, tt = toks(src), toks(tgt)
    tw = windows(tt)
    tset = set(tt)
    miss = []
    ms, mt = mem(st), mem(tt)
    if ms:
        if not mt or mt[1] != ms[1] or (ms[0] and mt[0] and ms[0] != mt[0]):
            miss.append('памʼять %s' % '/'.join(x for x in ms if x).replace('gb/', '/'))
    model_ok = 0
    codes = []
    for i, t in enumerate(st):
        if t in NET or re.fullmatch(r'20\d\d', t):
            continue
        if re.fullmatch(r'(\d+gb)?\d+(gb|tb)', t) or (ms and ms[0] == t + 'gb' and i + 1 < len(st) and re.fullmatch(r'\d+(gb|tb)', st[i + 1])):
            continue
        if not (has_digit(t) or t in ROMAN):
            continue
        if found(i, st, tw) or (t.isdigit() and (t + 'mm') in tset):
            if not is_code(t):
                model_ok += 1
        elif is_code(t):
            codes.append(t)
        else:
            miss.append(t)
    # інший лише артикул (колір чи комплектація тієї самої моделі): модель і памʼять збіглися, у цілі теж є артикул
    if codes and (miss or not model_ok or not any(is_code(v) for v in tt)):
        miss = codes + miss
    # модифікатор моделі: є з одного боку і немає з іншого
    sset = set(st)
    for m in sorted(MODS):
        if m in sset and m not in tset:
            miss.append(m)
        elif m in MODS_BACK and m in tset and m not in sset:
            miss.append('+' + m)
    return miss


def is_product_rule(r):
    """правило веде з товару на товар: ціль /p/{слаг} або жива сторінка товару поза картою сайту"""
    if r['Тип цілі'] == 'товар':
        return True
    t, s_ = r['Нова ціль'], r['Джерело для таблиці Redirect']
    return (r['Тип цілі'] == 'інше' and '/mfp/' not in t and t.strip('/').count('/') >= 2
            and t.split('/')[-1].count('-') >= 3 and s_.split('/')[-1].count('-') >= 3)


rows = list(csv.DictReader(open(KARTA, encoding='utf-8-sig')))
H = [h for h in rows[0].keys() if h != COL] + [COL]
for r in rows:
    r[COL] = ''
    # повторний запуск: знімаємо позначки попереднього проходу
    r['Що зроблено'] = '; '.join(a for a in r['Що зроблено'].split('; ') if not a.startswith(NOTE)) or 'без змін'

# 3. правило www: «/» на головну
for r in rows:
    if r['Джерело для таблиці Redirect'] == '/' and re.fullmatch(r'(https?:/+)?(www\.)?justbuy\.com\.ua/?', r['Нова ціль']):
        r['Що зроблено'] = NOMOVE

# 2. конфлікти: лишається правило з тим самим кодом моделі
by = collections.defaultdict(list)
for r in rows:
    by[r['Джерело для таблиці Redirect']].append(r)
swapped = []
for src, grp in by.items():
    if len(grp) < 2 or len({r['Нова ціль'] for r in grp}) < 2:
        continue
    keep = next((r for r in grp if not r['Що зроблено'].startswith('видалити')), grp[0])

    def score(r):
        if not is_product_rule(r):
            return (0, 0)
        st, tt = set(toks(src)), set(toks(r['Нова ціль']))
        return (1 if not check(src, r['Нова ціль']) else 0, len(st & tt))
    best = max(grp, key=lambda r: (score(r), r is keep))
    if best is not keep and score(best)[0] == 1 and score(best) > score(keep):
        swapped.append((keep['№'], best['№'], score(keep)[0] == 0))
        # старе «Що зроблено» лишеного правила відновлюємо з дії над ціллю
        acts = [a for a in best['Що зроблено'].split('; ') if not a.startswith('видалити')]
        best['Що зроблено'] = '; '.join(acts) or ('ціль переписано на /p/{слаг}' if '/p/' in best['Нова ціль'] else 'без змін')
        keep['Що зроблено'] = 'видалити: конфлікт, лишається правило № %s' % best['№']
        keep = best
    for r in grp:
        if r is not keep and r['Що зроблено'].startswith('видалити: конфлікт'):
            r['Що зроблено'] = 'видалити: конфлікт, лишається правило № %s' % keep['№']


def manual(r):
    return 'вручну' in r['Що зроблено'] or r['Нова ціль: перевірка'] in ('err', 'не перевірено')


def excluded(r):
    return r['Що зроблено'].startswith('видалити') or r['Що зроблено'].startswith('не переносити')


# 1. перевірка коду моделі для готових правил на товар
flag = 0
for r in rows:
    if excluded(r) or not is_product_rule(r):
        continue
    if manual(r):
        continue
    miss = check(r['Джерело для таблиці Redirect'], r['Нова ціль'])
    if miss:
        r[COL] = MARK
        r['Що зроблено'] = '%s (у цілі немає: %s); %s' % (NOTE, ', '.join(miss), r['Що зроблено'])
        flag += 1
    else:
        r[COL] = 'збігається'

w = csv.DictWriter(open(KARTA, 'w', newline='', encoding='utf-8-sig'), fieldnames=H)
w.writeheader()
w.writerows(rows)
man = [r for r in rows if not excluded(r) and manual(r)]
w = csv.DictWriter(open(MANUAL, 'w', newline='', encoding='utf-8-sig'), fieldnames=H)
w.writeheader()
w.writerows(man)

dele = [r for r in rows if excluded(r)]
ready = len(rows) - len(man) - len(dele)
conf = sum(1 for r in rows if r['Що зроблено'].startswith('видалити: конфлікт'))
print({'правил': len(rows), 'готових': ready, 'на ручну перевірку': len(man), 'з них за кодом моделі': flag,
       'видалити': sum(1 for r in dele if r['Що зроблено'].startswith('видалити')), 'з них конфлікти': conf,
       'не переносити': sum(1 for r in dele if r['Що зроблено'] == NOMOVE),
       'конфліктів з іншим лишеним правилом': len(swapped), 'з них лишене було з іншою моделлю': sum(1 for x in swapped if x[2]),
       'сума сходиться': ready + len(man) + len(dele) == len(rows)})
