# -*- coding: utf-8 -*-
"""김제시 누락 하위메뉴 확장 — 공식 사이트맵(전체메뉴) 기준.

basic_tab 부모병합 등으로 빠진 말단 하위메뉴(각자 menuCd 보유)를 사이트맵 트리에서
복원해 부모 아래 올바른 컬럼(D=4+tree_depth)·위치로 삽입한다. 부모행(병합 survivor,
M=탭수)은 자식이 분리되므로 자기 페이지로 Phase2~4 재수집(M=1 등). 기존 행은 보존.

L 판별은 김제 방식(본문 리스트클래스=게시판) + 미디어/KOGL은 전북 phase234 로직 재사용.
사용: python -X utf8 _김제_확장.py plan | run
"""
import os, sys, re, io, time, importlib.util, warnings
from urllib.parse import urljoin
from copy import copy
from concurrent.futures import ThreadPoolExecutor, as_completed
warnings.filterwarnings('ignore')
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8', line_buffering=True)
import requests
from bs4 import BeautifulSoup
import openpyxl

HERE = os.path.dirname(os.path.abspath(__file__))
def _imp(name, path):
    spec = importlib.util.spec_from_file_location(name, path)
    m = importlib.util.module_from_spec(spec); spec.loader.exec_module(m); return m
dd = _imp('dd', os.path.join(HERE, '_스크립트', '_dedup_all.py'))
ph = _imp('ph', os.path.join(HERE, '_스크립트', '_jeonbuk_phase234_all.py'))

XP = os.path.join(HERE, '작업파일', '광역_사이트맵', '전북특별자치도', '3.김제시', '전북특별자치도_김제시.xlsx')
BASE = 'https://www.gimje.go.kr'
ALLMENU = BASE + '/index.gimje?menuCd=DOM_000000107002000000'
LIST = re.compile(r'class="[^"]*(bbs_list|news_list|photo_list|video_list|magazine_list|gallery_list|board_list)[^"]*"')
NEWWIN = re.compile(r'\s*새\s*창\s*열림\s*$')
UA = {'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/120 Safari/537.36'}


def mc_of(s):
    m = re.search(r'menuCd=(DOM_\w+)', str(s) or ''); return m.group(1) if m else None


def parse_tree():
    """반환: nodes(dict mc->{label,url,depth,parent,children[]}), order(list of mc in DFS)."""
    r = requests.get(ALLMENU, headers=UA, verify=False, timeout=20)
    soup = BeautifulSoup(r.content, 'html.parser')
    smap = soup.select_one('div.sitemap')
    nodes = {}; order = []

    def add(mc, label, url, depth, parent):
        label = NEWWIN.sub('', label).strip()
        if mc in nodes:
            return
        nodes[mc] = {'label': label, 'url': url, 'depth': depth,
                     'parent': parent, 'children': []}
        order.append(mc)
        if parent and parent in nodes:
            nodes[parent]['children'].append(mc)

    def walk(ul, depth, parent):
        for li in ul.find_all('li', recursive=False):
            a = li.find('a', recursive=False) or li.find('a')
            if not a:
                continue
            mc = mc_of(a.get('href'))
            if not mc:
                continue
            label = a.get_text(' ', strip=True)
            url = urljoin(BASE, a.get('href'))
            add(mc, label, url, depth, parent)
            sub = li.find('ul', recursive=False)
            if sub:
                walk(sub, depth + 1, mc)

    for mdiv in smap.find_all('div', recursive=False):
        head = mdiv.find(['h2', 'h3', 'strong', 'a'])
        if not head:
            continue
        # 대분류 head 의 menuCd (a면) 아니면 라벨만 (컨테이너) — 트리 노드로 등록(depth0)
        hmc = mc_of(head.get('href')) if head.name == 'a' else None
        htxt = head.get_text(' ', strip=True)
        if not hmc:
            # 컨테이너 라벨용 가짜 mc
            hmc = 'CAT_' + str(len(nodes))
        add(hmc, htxt, urljoin(BASE, head.get('href')) if head.name == 'a' and head.get('href') else '', 0, None)
        topul = mdiv.find('ul')
        if topul:
            walk(topul, 1, hmc)
    return nodes, order


def subtree(nodes, mc):
    out = set()
    stack = [mc]
    while stack:
        x = stack.pop(); out.add(x)
        stack.extend(nodes[x]['children'])
    return out


def path_labels(nodes, mc):
    """mc 의 조상→자기 라벨 리스트 (depth 순)."""
    chain = []
    cur = mc
    while cur is not None:
        chain.append(cur)
        cur = nodes[cur]['parent']
    chain.reverse()
    return chain  # list of mc by depth


# ---- Phase 2~4 (김제 list-class L + 전북 미디어/KOGL) ----
def collect(session, url):
    out = {'L': '', 'M': '', 'N': '', 'O': '', 'P': '', 'Q': '', 'note': ''}
    try:
        r = session.get(url, headers=UA, verify=False, timeout=8, allow_redirects=True)
        meta = re.search(rb'<meta[^>]*charset=["\']?\s*([\w-]+)', r.content[:4096], re.I)
        r.encoding = meta.group(1).decode('ascii', 'ignore') if meta else r.apparent_encoding
        if r.status_code != 200:
            out['note'] = '접근 실패'; return out
        html = r.text
    except Exception:
        out['note'] = '접근 실패'; return out
    soup = BeautifulSoup(html, 'html.parser')
    body = ph.get_body(soup, ph.BODY_SEL)
    is_board = bool(LIST.search(html))
    if is_board:
        out['L'] = '게시판'
        _, cnt = ph.detect_form(body)
        out['M'] = cnt
    else:
        out['L'] = '페이지'; out['M'] = 1
    has_img, has_vid, has_txt = ph.detect_media(body)
    types, q = ph.detect_kogl(body)
    if is_board:
        for du in ph.extract_detail_urls(body, url, limit=2):
            try:
                dr = session.get(du, headers=UA, verify=False, timeout=8)
                ds = BeautifulSoup(dr.content, 'html.parser')
                db = ph.get_body(ds, ph.BODY_SEL)
                di, dv, dt = ph.detect_media(db)
                has_img |= di; has_vid |= dv; has_txt |= dt
                dt_types, dt_q = ph.detect_kogl(db)
                if dt_types and not types:
                    out['P'] = '게시물'
                types |= dt_types
                if dt_q == 'Y':
                    q = 'Y'
            except Exception:
                pass
    out['N'] = ph.n_string(has_txt, has_img, has_vid)
    if types:
        out['O'] = ','.join(f'{n}유형' for n in sorted(types))
        out['P'] = out['P'] or '게시판'
        out['Q'] = q or 'N'
    else:
        out['O'] = '미부착'
    return out


def main():
    mode = sys.argv[1] if len(sys.argv) > 1 else 'plan'
    nodes, order = parse_tree()
    real = {mc: n for mc, n in nodes.items() if not mc.startswith('CAT_')}
    leaves = [mc for mc in order if not mc.startswith('CAT_') and not nodes[mc]['children']]

    wb = openpyxl.load_workbook(XP); ws = wb.active
    rows = dd.load_flat(ws)
    pos = {}; row_by_mc = {}
    for i, r in enumerate(rows):
        mc = mc_of(r['vals'].get(11))
        if mc:
            pos[mc] = i; row_by_mc[mc] = r
    have = set(row_by_mc)

    missing = [mc for mc in leaves if mc not in have]
    # parents that will gain children
    gain_parents = {}
    for mc in missing:
        p = nodes[mc]['parent']
        gain_parents.setdefault(p, []).append(mc)

    # anchor index for each parent group: after last existing row in subtree of parent
    inserts = {}  # anchor_idx -> [mc,...] in tree order
    no_anchor = []
    for p, kids in gain_parents.items():
        # nearest existing ancestor (incl parent) to anchor after its subtree
        anc = p
        anchor_idx = None
        while anc is not None:
            st = subtree(nodes, anc)
            present = [pos[m] for m in st if m in pos]
            if present:
                anchor_idx = max(present); break
            anc = nodes[anc]['parent']
        if anchor_idx is None:
            no_anchor.append((p, kids)); continue
        # order kids by their order in tree (children list of p)
        ordered = [m for m in nodes[p]['children'] if m in kids]
        inserts.setdefault(anchor_idx, []).extend(ordered)

    survivors = [p for p in gain_parents if p in have]
    print('=== 김제 확장 plan ===')
    print(f'트리 노드(실): {len(real)}  말단leaf: {len(leaves)}  기존행: {len(rows)}')
    print(f'누락 말단메뉴(추가대상): {len(missing)}')
    print(f'자식 얻는 부모: {len(gain_parents)}  (그중 기존행=재수집대상 survivor: {len(survivors)})')
    print(f'앵커 못찾음: {len(no_anchor)}')
    # sample
    def lab(mc):
        return ' > '.join(nodes[m]['label'] for m in path_labels(nodes, mc) if not m.startswith('CAT_') or nodes[m]['label'])
    print('\n[샘플 추가 행 20]')
    for mc in missing[:20]:
        d = nodes[mc]['depth']; col = chr(ord('A') + 3 + d)
        print(f'  +{col}({d}) {lab(mc)}  ({mc})')
    print('\n[survivor 부모 재수집 대상]')
    for p in survivors:
        r = row_by_mc[p]; v = r['vals']
        print(f'  r{r["src"]} M={v.get(13)} L={v.get(12)} [{nodes[p]["label"]}] 자식 {len(gain_parents[p])}개')
    if no_anchor:
        print('\n[!] 앵커 못찾은 그룹:')
        for p, kids in no_anchor:
            print(f'   parent {p} kids {len(kids)}')

    if mode != 'run':
        return

    # ---- build new rows + fetch ----
    session = requests.Session()
    template = row_by_mc.get(list(survivors)[0]) if survivors else rows[0]
    tmpl_styles = template['styles']

    def make_row(mc):
        v = {c: None for c in range(1, dd.MAXCOL + 1)}
        v[3] = '김제시'
        chain = path_labels(nodes, mc)
        for m in chain:
            d = nodes[m]['depth']
            col = 4 + d
            if col <= 10 and nodes[m]['label']:
                v[col] = nodes[m]['label']
        v[11] = nodes[mc]['url']
        return {'src': None, 'vals': v,
                'styles': {c: tuple(copy(x) if hasattr(x, 'copy') or True else x for x in tmpl_styles[c]) for c in tmpl_styles},
                'hyperlink': nodes[mc]['url']}

    # fetch all missing + survivor parents
    fetch_targets = list(missing) + survivors
    print(f'\nPhase2~4 수집 {len(fetch_targets)}건 ...')
    res = {}
    t0 = time.time()
    def work(mc):
        return mc, collect(session, nodes[mc]['url'])
    with ThreadPoolExecutor(max_workers=10) as ex:
        futs = [ex.submit(work, mc) for mc in fetch_targets]
        done = 0
        for f in as_completed(futs):
            mc, o = f.result(); res[mc] = o; done += 1
            if done % 30 == 0 or done == len(fetch_targets):
                print(f'  {done}/{len(fetch_targets)} ({time.time()-t0:.0f}s)')

    # apply to survivors (overwrite own page data; M back to own)
    for p in survivors:
        v = row_by_mc[p]['vals']; o = res.get(p, {})
        for col, key in [(12, 'L'), (13, 'M'), (14, 'N'), (15, 'O'), (16, 'P'), (17, 'Q')]:
            if o.get(key) != '':
                v[col] = o[key]

    # build new row objects with phase data
    new_obj = {}
    for mc in missing:
        ro = make_row(mc); o = res.get(mc, {})
        v = ro['vals']
        for col, key in [(12, 'L'), (13, 'M'), (14, 'N'), (15, 'O'), (16, 'P'), (17, 'Q')]:
            if o.get(key) != '':
                v[col] = o[key]
        if o.get('note'):
            v[19] = o['note']
        new_obj[mc] = ro

    # assemble final ordered list
    out = []
    for i, r in enumerate(rows):
        out.append(r)
        if i in inserts:
            for mc in inserts[i]:
                out.append(new_obj[mc])

    # backup + write
    import shutil
    bak = XP.replace('.xlsx', '_backup_확장전.xlsx')
    shutil.copy(XP, bak)
    dd.write_back(ws, out)
    try:
        wb.save(XP)
        print(f'\n저장완료 {len(rows)}→{len(out)}행. 백업 {os.path.basename(bak)}')
    except PermissionError:
        alt = XP.replace('.xlsx', '_LP.xlsx'); wb.save(alt)
        print(f'\n!! 원본 잠김 → {os.path.basename(alt)} 로 저장')


if __name__ == '__main__':
    main()
