# Таблиця перенесення правил Cloudflare Bulk Redirects у таблицю Redirect застосунку (ТЗ 4.8).
# Запуск: python3 build_redirect_map.py bulk_redirects.csv <тека з sitemap*.xml> <тека виходу> [live.json] [live_loc.json]
# Необовʼязково CATS_JSON=<файл> з переліком категорій, якщо карта категорій лежить окремо.
# live.json: {"адреса": код відповіді} для цілей поза картою сайту; live_loc.json: кінцеві адреси цілей, що самі віддають 301.
# Логіка: нормалізація джерела; ціль товару звіряється з кодом товару в джерелі (сусідній рядок того ж списку
# або один товар каталогу з тим самим кодом), інакше рядок іде на ручну перевірку; ланцюги розгортаються;
# ціль товару переписується на /{мова}/p/{слаг}; фільтри в нижньому регістрі; ціль 404 веде на категорію;
# петлі, дублі і конфлікти позначаються на видалення.
import csv,re,sys,os,json,glob,collections,difflib
BULK,SMDIR,OUT=sys.argv[1],sys.argv[2],sys.argv[3]
LIVE=json.load(open(sys.argv[4])) if len(sys.argv)>4 and os.path.exists(sys.argv[4]) else {}
# необовʼязково: кінцеві адреси цілей, що самі віддають 301 (live_loc.json поруч з live.json)
LOC=json.load(open(sys.argv[5])) if len(sys.argv)>5 and os.path.exists(sys.argv[5]) else {}
def path_of(u):
    u=u.strip();u=re.sub(r'^https?://','',u);u=re.sub(r'^(www\.)?justbuy\.com\.ua','',u)
    u=u.split('#')[0];return u or '/'
def norm(p):
    p=p.split('?')[0].lower()
    if len(p)>1:p=p.rstrip('/')
    return p or '/'
prod={};cats=set();coll=set();brands=set();other=set()
for f in glob.glob(os.path.join(SMDIR,'*')):
    x=open(f,errors='replace').read()
    for u in re.findall(r'<loc>([^<]+)</loc>',x):
        if u.endswith('.xml'):continue
        p=norm(path_of(u));segs=p.strip('/').split('/')
        n=os.path.basename(f)
        if 'products' in n: prod.setdefault(segs[-1],set()).add(p)
        elif 'categor' in n: cats.add(p)
        elif 'collections' in n: coll.add(p)
        elif 'brands' in n: brands.add(p)
        else: other.add(p)
CJ=os.environ.get('CATS_JSON')
if CJ:
    for k in json.load(open(CJ)):
        k=norm(k);cats.add(k)
        if k.startswith('/ua/'):
            cats.add('/ru/'+k[4:]);cats.add('/en/'+k[4:])
inmap=lambda p: p in cats or p in coll or p in brands or p in other or any(p in v for v in prod.values())
rows=list(csv.DictReader(open(BULK,encoding='utf-8-sig')))
src_index=collections.defaultdict(list)
for i,r in enumerate(rows): src_index[norm(path_of(r['source_url']))].append(i)
UNIT=re.compile(r'^\d+(gb|tb|mah|w|wh|mm|hz|kg|g|l|m|cm|v|a|mp|k|in|x\d+)?$')
def code(p):
    for x in reversed([x for x in re.split(r'[-/]',p.strip('/').split('/')[-1]) if x]):
        if re.fullmatch(r'\d{5,}',x) and not re.fullmatch(r'20\d\d',x): return x
        if len(x)>=4 and re.search(r'\d',x) and re.search(r'[a-z]',x) and not UNIT.match(x): return x
    return None
def tcode(tgt):
    t=[x for x in tgt.strip('/').split('/')[-1].split('-') if x]
    return t[-1] if t and re.search(r'\d',t[-1]) and len(t[-1])>=4 else None
def matches(src,tgt):
    c=tcode(tgt)
    return c is None or c in src.replace('-','').replace('/','')
def classify(p):
    segs=p.strip('/').split('/');lang=segs[0] if segs and segs[0] in('ua','ru','en') else 'ua'
    if segs and segs[-1] in prod and '/mfp/' not in p: return 'товар',lang,segs[-1]
    if '/mfp/' in p: return 'фільтр',lang,None
    if p in cats: return 'категорія',lang,None
    if p in brands: return 'бренд',lang,None
    return 'інше',lang,None
def newtarget(p):
    k,lang,slug=classify(p)
    if k=='товар': return '/%s/p/%s'%(lang,slug),k
    return p,k
def parent_cat(p):
    segs=p.strip('/').split('/')
    for i in range(len(segs)-1,0,-1):
        q='/'+'/'.join(segs[:i])
        if q in cats: return q
    return None
def slugsim(src,tgt):
    a=set(x for x in re.split(r'[-/]',src.split('/')[-1]) if len(x)>2);b=set(x for x in re.split(r'[-/]',tgt.split('/')[-1]) if len(x)>2)
    return len(a&b)>=max(2,int(0.4*min(len(a),len(b)))) if a and b else True
pcode=collections.defaultdict(set)
for s_ in prod:
    for x in s_.split('-'):
        if len(x)>=4 and re.search(r'\d',x) and re.search(r'[a-z]',x) and not UNIT.match(x): pcode[x].add(s_)
        elif re.fullmatch(r'\d{5,}',x): pcode[x].add(s_)
catlist=collections.defaultdict(list)
for c_ in cats: catlist[c_.split('/')[1]].append(c_)
bycode=collections.defaultdict(list)
for r_ in rows:
    l_=r_.get('\ufefflist_name',r_.get('list_name'));tg=norm(path_of(r_['target_url']));sc_=code(norm(path_of(r_['source_url'])))
    if sc_ and sc_ in tg.replace('-',''): bycode[(l_,sc_)].append(tg)
out=[];stats=collections.Counter()
for i,r in enumerate(rows):
    lst=r['﻿list_name'] if '﻿list_name' in r else r['list_name']
    src=norm(path_of(r['source_url']));old=path_of(r['target_url']);tgt=norm(old);acts=[]
    if src.lower()!=path_of(r['source_url']).split('?')[0] : acts.append('джерело нормалізовано')
    sc=code(src)
    if sc and classify(tgt)[0]=='товар' and sc.replace('_','') not in tgt.replace('-','').replace('_',''):
        cand=sorted(set(c for c in bycode.get((lst,sc),[]) if c!=tgt))
        pc_=sorted(pcode.get(sc,()))
        if len(cand)==1:
            tgt=cand[0];acts.append('зсув на сусідній товар виправлено')
        elif len(pc_)==1:
            tgt='/%s/p/%s'%(classify(src)[1],pc_[0]);acts.append('зсув виправлено за кодом товару в каталозі')
        else:
            acts.append('ціль з іншим кодом товару: перевірити вручну')
    elif not sc and lst=='redirect_list' and not slugsim(src,tgt):
        for j in (i+1,i-1):
            if 0<=j<len(rows) and slugsim(src,norm(path_of(rows[j]['target_url']))):
                tgt=norm(path_of(rows[j]['target_url']));acts.append('зсув на сусідній товар виправлено');break
    seen={src};steps=0
    while tgt in src_index and steps<6:
        nxt=norm(path_of(rows[src_index[tgt][0]]['target_url']))
        if nxt in seen: break
        seen.add(tgt);tgt=nxt;steps+=1
    if steps: acts.append('ланцюг розгорнуто (%d)'%steps)
    nt,kind=newtarget(tgt)
    if nt!=tgt and kind=='товар': acts.append('ціль переписано на /p/{слаг}')
    if kind=='фільтр' and old!=old.lower(): acts.append('ціль у нижньому регістрі')
    status='у карті сайту' if (kind=='товар' or inmap(nt) or inmap(tgt)) else str(LIVE.get(nt,LIVE.get(tgt,'не перевірено')))
    if status in('301','302','307','308'):
        lc=LOC.get(nt) or LOC.get(tgt)
        if lc and str(lc.get('code'))=='200' and lc['final']!=src:
            nt=norm(lc['final']);acts.append('ціль сама віддає 301: записано кінцеву адресу');status='200'
        elif lc and str(lc.get('code')) in('404','410'): status='404'
        else: acts.append('ціль сама віддає 301: кінцеву адресу перевірити вручну')
    if status in('404','410'):
        m_=re.match(r'^/(ua|ru|en)/(?:smartfoni-plansheti-ta-aksesuari-)?[cs]martfoni-[cs]martfoni-([a-z0-9]+)(?:/mfp/.*)?$',tgt)
        pc='/%s/smartfoni-plansheti-ta-aksesuari-smartfoni/mfp/brand-%s'%m_.groups() if m_ else parent_cat(tgt)
        if pc: nt=pc;kind='фільтр' if '/mfp/' in pc else 'категорія';acts.append('ціль 404 замінено на категорію');status='у карті сайту'
        else:
            base=tgt.split('/mfp/')[0];lang_=base.split('/')[1] if base.count('/')>=2 else 'ua'
            dg=set(re.findall(r'\d+',base))
            cl=[c_ for c_ in difflib.get_close_matches(base,catlist.get(lang_,[]),n=3,cutoff=0.9) if set(re.findall(r'\d+',c_))==dg][:1]
            if cl:
                nt=cl[0];kind='категорія';fb=re.search(r'/mfp/brand[-,]([a-z0-9_]+)$',tgt)
                if fb: nt+='/mfp/brand-'+fb.group(1);kind='фільтр'
                acts.append('ціль 404 замінено на категорію з новою адресою');status='у карті сайту'
            else: acts.append('ціль 404: рішення вручну')
    if nt==src: acts=['видалити: петля'];
    out.append([i+1,lst,r['source_url'],src,r['target_url'],nt,'; '.join(acts) or 'без змін',status,kind])
    for a in acts: stats[re.sub(r' \(\d+\)','',a)]+=1
# конфлікти: одне джерело, різні цілі
by=collections.defaultdict(list)
for o in out: by[o[3]].append(o)
for s,lst in by.items():
    if len({o[5] for o in lst})>1:
        keep=next((o for o in lst if o[7]=='у карті сайту'),lst[0])
        for o in lst:
            if o is not keep: o[6]='видалити: конфлікт, лишається правило № %d'%keep[0];stats['видалити: конфлікт']+=1
    elif len(lst)>1:
        for o in lst[1:]: o[6]='видалити: дубль правила № %d'%lst[0][0];stats['видалити: дубль']+=1
H=['№','Список Cloudflare','Джерело в Cloudflare','Джерело для таблиці Redirect','Стара ціль','Нова ціль','Що зроблено','Нова ціль: перевірка','Тип цілі']
w=csv.writer(open(os.path.join(OUT,'transfer.csv'),'w',newline=''));w.writerow(H);w.writerows(out)
w=csv.writer(open(os.path.join(OUT,'review.csv'),'w',newline=''));w.writerow(H)
w.writerows([o for o in out if 'вручну' in o[6] or o[7] in('err','не перевірено')])
need=sorted({o[5] for o in out if o[7]=='не перевірено'})
json.dump({'rules':len(out),'stats':stats,'products_in_map':len(prod),'cats':len(cats),'coll':len(coll),'need_live':len(need)},open(os.path.join(OUT,'stats.json'),'w'),ensure_ascii=False,indent=1)
open(os.path.join(OUT,'need_live.txt'),'w').write('\n'.join(need))
print(json.dumps({'rules':len(out),'stats':stats,'need_live':len(need),'prod':len(prod),'cats':len(cats),'coll':len(coll)},ensure_ascii=False))
