"""
DZW5 파일 서버 (포트 8090)
HTML + API(/api/browse, /api/parse-jbi, /api/list-jbi) 동일 포트 제공
ngrok 인터스티셜 자동 우회 헤더 포함
"""
import http.server, os, json, glob, urllib.parse, math, time, base64, sys

DIR = os.path.dirname(os.path.abspath(__file__))

# 암묵지 엔진(TIE) 연동 — 같은 출처로 JBI 생성근거 제공
ENGINE_DIR = r'E:\도진웍스허브\암묵지엔진'
if os.path.isdir(ENGINE_DIR) and ENGINE_DIR not in sys.path:
    sys.path.insert(0, ENGINE_DIR)
_ENGINE_MODEL = '에버다임'   # 달인 자산 기준모델(검증완료 프로그램 코퍼스)

# 공정서 근거 규칙표 (출처 고정 — 여기 없는 의미는 지어내지 않음)
# 출처: E:\VLA시스템\카고텍\12500_knuckle_boom\용접\!_12500_용접공정서.md
_WPS_SRC = r'E:\VLA시스템\카고텍\12500_knuckle_boom\용접\!_12500_용접공정서.md'
_RULES = {
    'movl':   {'act': '직선 본용접', 'why': '직선부 40~50cm/min — 고장력강 입열 관리 범위', 'rule': '', 'src': '공정서 §1 속도표'},
    'movc':   {'act': '곡선(R) 본용접', 'why': 'R곡선 40~45cm/min · 하단R MOVC 필수, 기저 진입 2mm 하향', 'rule': 'MOT-02', 'src': '공정서 §1·§3'},
    'narrow': {'act': '협소·저속 용접', 'why': '열입력 최소화 구간(≤32cm/min)', 'rule': '', 'src': '공정서 §1'},
    'arcon':  {'act': '아크 시작', 'why': '아크조건파일 ASF — 전류·전압은 용접기 WELDER.DAT 기준', 'rule': '', 'src': '공정서 §1'},
    'arcof':  {'act': '아크 정지', 'why': '', 'rule': '', 'src': '자동분류-근거없음'},
    'appr':   {'act': '이송(에어무브)', 'why': '용접 외 이동 — 근거 규칙 없음', 'rule': '', 'src': '자동분류-근거없음'},
}

def _basis_records(path):
    """명령별 근거 레코드 + 출처 집계(연동정보). 공정서 규칙표를 실시간 적용."""
    import jbi_extract as JX
    moves, arc, pos = JX.parse(path)
    # move index → (arcOn, cond)
    state = []
    on = False; cond = None
    ev = {i: (k, c) for (i, k, c) in arc}
    for i in range(len(moves)):
        if i in ev:
            k, c = ev[i]
            if k == 'ON': on = True; cond = c
            elif k == 'OF': on = False
        state.append((on, cond))
    recs = []
    for i, m in enumerate(moves):
        onst, cond = state[i]
        kind = m['kind']; v = m['v']
        if kind == 'J' or not onst:
            key = 'appr'
        elif kind == 'C':
            key = 'movc'
        elif v <= 5.3:
            key = 'narrow'
        else:
            key = 'movl'
        R = _RULES[key]
        recs.append({'idx': i + 1, 'inst': 'MOV' + kind, 'c': m['c'], 'v': v,
                     'cmmin': round(v * 6, 0), 'asf': cond if onst else None,
                     'act': R['act'], 'why': R['why'], 'rule': R['rule'], 'src': R['src'],
                     'weld': bool(onst)})
    # 출처 집계 (연동정보 = provenance)
    rules = {}; srcs = {}; cited = 0; nogr = 0
    for rr in recs:
        if rr['rule']:
            rules[rr['rule']] = rules.get(rr['rule'], 0) + 1
        srcs[rr['src']] = srcs.get(rr['src'], 0) + 1
        if '근거없음' in rr['src']:
            nogr += 1
        else:
            cited += 1
    prov = {'ruleCounts': rules, 'srcCounts': srcs, 'cited': cited, 'nogr': nogr,
            'total': len(recs), 'wpsFile': _WPS_SRC}
    return recs, prov

_IMG_EXT = {'.jpg', '.jpeg', '.png', '.bmp', '.gif', '.webp'}
_THUMB_CACHE = {}   # (path, mtime, w) → jpeg bytes (격자 썸네일)

def _thumb(path, w):
    try:
        mt = os.path.getmtime(path)
    except Exception:
        return None
    key = (path, int(mt), w)
    if key in _THUMB_CACHE:
        return _THUMB_CACHE[key]
    try:
        from PIL import Image
        import io as _io
        im = Image.open(path)
        im.thumbnail((w, w * 2))
        if im.mode not in ('RGB', 'L'):
            im = im.convert('RGB')
        buf = _io.BytesIO()
        im.save(buf, 'JPEG', quality=72)
        data = buf.getvalue()
        if len(_THUMB_CACHE) > 4000:
            _THUMB_CACHE.clear()
        _THUMB_CACHE[key] = data
        return data
    except Exception:
        return None

_PHOTO_ROOTS = [r'E:\VLA시스템', r'E:\06_고객사데이터']
_JBI_LOC_CACHE = {}

def _locate_jbi(name):
    """정확한 파일명으로 실제 JBI 파일 위치를 모두 찾는다(복사본 여러 개일 수 있음)."""
    if not name:
        return []
    if name in _JBI_LOC_CACHE:
        return _JBI_LOC_CACHE[name]
    target = name.lower()
    hits = []
    for root in _PHOTO_ROOTS:
        if not os.path.isdir(root):
            continue
        for dp, dns, fns in os.walk(root):
            for f in fns:
                if f.lower() == target:
                    hits.append(os.path.join(dp, f))
            if len(hits) >= 12:
                break
    _JBI_LOC_CACHE[name] = hits
    return hits

def weld_photos(jbi_path, name='', cap_cat=60):
    """로드된 JBI가 '실제로 있는 위치'에서 제품 폴더를 찾아 공정별 현장사진을 모은다.
    경로가 있으면 그대로, 없으면 파일명으로 실제 파일 위치를 찾아 그 제품에 고정.
    가접·용접·불량·완성차 등 하위폴더 = 공정 카테고리. 실제 파일만."""
    def gather(jp):
        d = os.path.dirname(jp) if jp else ''
        cands = [d, os.path.dirname(d), os.path.dirname(os.path.dirname(d))] if d else []
        for root in cands:
            if not root or not os.path.isdir(root):
                continue
            cats = {}
            try:
                entries = os.listdir(root)
            except Exception:
                continue
            for e in entries:
                full = os.path.join(root, e)
                if os.path.isdir(full):
                    imgs = []
                    for dp, dn, fn in os.walk(full):
                        for f in fn:
                            if os.path.splitext(f)[1].lower() in _IMG_EXT:
                                imgs.append(os.path.join(dp, f))
                                if len(imgs) >= cap_cat:
                                    break
                        if len(imgs) >= cap_cat:
                            break
                    if imgs:
                        cats[e] = imgs
                elif os.path.splitext(e)[1].lower() in _IMG_EXT:
                    cats.setdefault('기타', []).append(full)
            if cats:
                return {'root': root, 'categories': [
                    {'name': k, 'count': len(v), 'photos': [{'path': p, 'name': os.path.basename(p)} for p in v]}
                    for k, v in sorted(cats.items(), key=lambda x: -len(x[1]))]}
        return None

    # 1) 실제 경로가 주어지면 그것이 정답(그 제품 확정)
    if jbi_path and os.path.isfile(jbi_path):
        return gather(jbi_path) or {'root': None, 'categories': []}
    # 2) 경로 없으면 파일명으로 실제 파일 위치들을 찾아, 사진이 가장 풍부한 제품 폴더 채택
    nm = name or os.path.basename(jbi_path or '')
    best = None; best_n = -1
    if nm.lower().endswith('.jbi'):
        for jp in _locate_jbi(nm):
            r = gather(jp)
            if r:
                tot = sum(c['count'] for c in r['categories'])
                if tot > best_n:
                    best_n = tot; best = r
    return best or {'root': None, 'categories': []}

def engine_basis(path):
    """로드된 JBI를 달인 분포에 대조해 '생성 근거'를 낸다. 지어낸 수치 없음."""
    try:
        import tie_engine as TE
    except Exception as e:
        return {'error': 'engine import: ' + str(e)}
    try:
        import io, contextlib
        buf = io.StringIO()
        with contextlib.redirect_stdout(buf):
            r = TE.recommend(path, _ENGINE_MODEL)
    except Exception as e:
        return {'error': 'engine run: ' + str(e)}
    same = ('everdigm' in path.lower()) or ('에버다임' in path)
    rows = [{'seg': si, 'idx': i, 'cond': c, 'suggest': p50, 'actual': act,
             'band': list(band) if band else None, 'kind': kind, 'status': stt}
            for (si, i, c, p50, act, band, kind, stt) in r['rows']]
    dmap = [{'cond': c, 'n': n, 'p50': p50, 'band': list(band), 'cv': cv, 'kind': kind}
            for (c, n, p50, band, cv, kind) in r['dmap']]
    try:
        recs, prov = _basis_records(path)
    except Exception as e:
        recs, prov = [], {'error': str(e)}
    stt = os.stat(path)
    return {'model': r['model'], 'programs': r['programs'], 'prefill': r['prefill'],
            'hit': r['hit'], 'tot': r['tot'], 'rows': rows, 'dmap': dmap,
            'sameCorpus': same, 'records': recs, 'provenance': prov,
            'mtime': time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(stt.st_mtime)),
            'sizeKB': round(stt.st_size / 1024, 1), 'source': os.path.basename(path)}

# 캡처 저장 폴더 (POST /save)
SAVE_DIR = os.path.join(DIR, 'shots')
os.makedirs(SAVE_DIR, exist_ok=True)

# JBI 목록 캐시 (60초)
_jbi_cache = {'ts': 0, 'data': []}

# PULSE → DEG 스케일 (야스카와 AR2010 — 필요 시 수정)
PULSE_SCALE = {'S': 3600.0, 'L': 3600.0, 'U': 3600.0, 'R': 1800.0, 'B': 1800.0, 'T': 1800.0}

def list_jbi_files():
    now = time.time()
    if now - _jbi_cache['ts'] < 60:
        return _jbi_cache['data']
    roots = ['E:\\']
    items = []
    for root in roots:
        for ext in ('*.JBI', '*.jbi'):
            for p in glob.glob(os.path.join(root, '**', ext), recursive=True):
                folder = os.path.basename(os.path.dirname(p))
                product = os.path.basename(os.path.dirname(os.path.dirname(p)))
                items.append({'name': os.path.basename(p), 'path': p, 'folder': folder, 'product': product})
    items.sort(key=lambda x: x['name'])
    _jbi_cache['ts'] = now
    _jbi_cache['data'] = items
    return items

def parse_jbi(path):
    with open(path, 'r', encoding='utf-8', errors='replace') as f:
        lines = [l.strip() for l in f.readlines()]

    c_points = {}
    instructions = []
    sec = ''
    for line in lines:
        if line == '//POS': sec = 'pos'; continue
        if line == '//INST': sec = 'inst'; continue
        if line.startswith('//') and not line.startswith('///'):
            sec = ''; continue
        if sec == 'pos' and line.startswith('C') and not line.startswith('//'):
            m = line.split('=')
            if len(m) == 2:
                try:
                    idx = int(m[0][1:])
                    vals = list(map(int, m[1].split(',')))
                    if len(vals) >= 6:
                        c_points[idx] = vals[:6]
                except: pass
        if sec == 'inst':
            for cmd in ('MOVJ', 'MOVL', 'MOVC', 'MOVS'):
                if line.startswith(cmd):
                    parts = line.split()
                    if len(parts) >= 2 and parts[1].startswith('C'):
                        try:
                            ci = int(parts[1][1:])
                            instructions.append({'cmd': cmd, 'ci': ci})
                        except: pass
                    break
            if line.startswith('ARCON'):
                instructions.append({'cmd': 'ARCON', 'ci': -1})
            elif line.startswith('ARCOF'):
                instructions.append({'cmd': 'ARCOF', 'ci': -1})

    # 명령에서 실제 이동 포인트 순서 추출
    poses = []
    arc = False
    arc_list = []
    move_types = []
    for inst in instructions:
        if inst['cmd'] == 'ARCON':
            arc = True
        elif inst['cmd'] == 'ARCOF':
            arc = False
        elif inst['ci'] >= 0 and inst['ci'] in c_points:
            p = c_points[inst['ci']]
            sc = PULSE_SCALE
            deg = {
                'S': p[0] / sc['S'], 'L': p[1] / sc['L'], 'U': p[2] / sc['U'],
                'R': p[3] / sc['R'], 'B': p[4] / sc['B'], 'T': p[5] / sc['T']
            }
            poses.append(deg)
            arc_list.append(1 if arc else 0)
            move_types.append(inst['cmd'])

    if not poses:
        return None

    # blocks 생성 (ARCON/ARCOF 기준)
    blocks = []
    in_weld = False
    seg_start = 0
    seg_label_line = 1
    seg_label_weld = 1
    for i, a in enumerate(arc_list):
        if a == 1 and not in_weld:
            if i > seg_start:
                blocks.append({'s': seg_start, 'e': i, 'cat': 'line', 'label': f'이송 {seg_label_line}'})
                seg_label_line += 1
            seg_start = i
            in_weld = True
        elif a == 0 and in_weld:
            blocks.append({'s': seg_start, 'e': i, 'cat': 'weld', 'label': f'용접 {seg_label_weld}'})
            seg_label_weld += 1
            seg_start = i
            in_weld = False
    if seg_start < len(arc_list):
        cat = 'weld' if in_weld else 'line'
        label = f'용접 {seg_label_weld}' if in_weld else f'이송 {seg_label_line}'
        blocks.append({'s': seg_start, 'e': len(arc_list), 'cat': cat, 'label': label})

    if not blocks:
        blocks = [{'s': 0, 'e': len(poses), 'cat': 'line', 'label': 'HOME'}]

    n = len(poses)
    # cum: 균등 분배 (step 기준)
    cum = [float(i) for i in range(n)]
    total = float(n - 1) if n > 1 else 1.0

    return {
        'n': n,
        'totalSteps': n - 1,
        'stepPoses': poses,
        'arc': arc_list,
        'moveType': move_types,
        'blocks': blocks,
        'cum': cum,
        'total': total,
        'source': os.path.basename(path)
    }


class Handler(http.server.SimpleHTTPRequestHandler):
    def __init__(self, *a, **kw):
        super().__init__(*a, directory=DIR, **kw)

    def do_OPTIONS(self):
        self.send_response(200)
        self.end_headers()

    def do_POST(self):
        parsed = urllib.parse.urlparse(self.path)
        if parsed.path == '/clicklog':
            try:
                n = int(self.headers.get('Content-Length', 0))
                body = self.rfile.read(n).decode('utf-8', 'replace')
                with open(os.path.join(DIR, '_click_trace.log'), 'a', encoding='utf-8') as fp:
                    import time as _t; fp.write(_t.strftime('%H:%M:%S ') + body + chr(10))
                self.send_response(200); self.end_headers(); self.wfile.write(b'ok')
            except Exception:
                self.send_response(500); self.end_headers()
            return
        # [2026-07-19 CEO] 제관 부품별 공정 저장 — fab_process.js 가 process.json 을 저장.
        #   경로: /save-process?model=<모델>  →  fab_models/parts/<모델>/process.json
        #   기존 로직 무수정, 라우트만 추가. 경로탈출 차단(basename) + JSON 검증(깨진 데이터 덮어쓰기 방지).
        if parsed.path == '/save-process':
            try:
                q = urllib.parse.parse_qs(parsed.query)
                model = (q.get('model') or [''])[0]
                model = os.path.basename(model.replace('\\', '/'))  # 경로구분자 제거 → 폴더 탈출 차단
                base = os.path.join(DIR, 'fab_models', 'parts', model)
                if not model or not os.path.isdir(base):
                    return self._json({'ok': False, 'err': '알 수 없는 모델: ' + model}, 400)
                length = int(self.headers.get('Content-Length', 0))
                body = self.rfile.read(length).decode('utf-8', errors='replace')
                json.loads(body)  # JSON 검증 — 깨진 데이터로 덮어쓰기 방지
                with open(os.path.join(base, 'process.json'), 'w', encoding='utf-8') as f:
                    f.write(body)
                print(f'process saved: {model}/process.json ({length}B)')
                return self._json({'ok': True, 'model': model, 'bytes': length})
            except Exception as e:
                return self._json({'ok': False, 'err': str(e)[:300]}, 500)
        # [2026-07-20 CEO] 제관 부품별 주석 저장 — fab_notes.js 가 notes.json 을 저장.
        #   경로: /save-notes?model=<모델>  →  fab_models/parts/<모델>/notes.json
        #   /save-process 와 동일 가드: 경로탈출 차단(basename) + JSON 검증(깨진 데이터 덮어쓰기 방지). 기존 로직 무수정, 라우트만 추가.
        if parsed.path == '/save-notes':
            try:
                q = urllib.parse.parse_qs(parsed.query)
                model = (q.get('model') or [''])[0]
                model = os.path.basename(model.replace('\\', '/'))  # 경로구분자 제거 → 폴더 탈출 차단
                base = os.path.join(DIR, 'fab_models', 'parts', model)
                if not model or not os.path.isdir(base):
                    return self._json({'ok': False, 'err': '알 수 없는 모델: ' + model}, 400)
                length = int(self.headers.get('Content-Length', 0))
                body = self.rfile.read(length).decode('utf-8', errors='replace')
                json.loads(body)  # JSON 검증 — 깨진 데이터로 덮어쓰기 방지
                with open(os.path.join(base, 'notes.json'), 'w', encoding='utf-8') as f:
                    f.write(body)
                print(f'notes saved: {model}/notes.json ({length}B)')
                return self._json({'ok': True, 'model': model, 'bytes': length})
            except Exception as e:
                return self._json({'ok': False, 'err': str(e)[:300]}, 500)
        # [2026-07-19 CEO] 뷰어 내 타 로봇 번역 — 작업기술서 받아 dzw_translate.py 실행, 산출물 반환
        if parsed.path == '/translate':
            try:
                import subprocess, tempfile, glob as _glob
                q = urllib.parse.parse_qs(parsed.query)
                robot = (q.get('robot') or ['csv'])[0]
                # 허용 목록만 (임의 모듈 실행 차단)
                ALLOW = {'kuka': 'post:KUKA_KRC4', 'abb': 'post:ABB_RAPID_IRC5', 'hyundai': 'post:Hyundai',
                         'fanuc': 'post:Fanuc_R30i', 'doosan': 'post:Doosan_Robotics', 'csv': 'csv',
                         'krl': 'post:KUKA_KRC4', 'rapid': 'post:ABB_RAPID_IRC5'}
                if robot not in ALLOW:
                    return self._json({'ok': False, 'err': '미지원 로봇: ' + robot + ' (지원: ' + ','.join(sorted(set(ALLOW))) + ')'}, 400)
                length = int(self.headers.get('Content-Length', 0))
                body = self.rfile.read(length).decode('utf-8', errors='replace')
                ws = json.loads(body)
                assert ws.get('format') == 'dozikworks-workspec'
                tmp = tempfile.mkdtemp()
                src = os.path.join(tmp, 'v.workspec.json')
                with open(src, 'w', encoding='utf-8') as f:
                    f.write(body)
                r = subprocess.run([sys.executable, os.path.join(DIR, 'dzw_translate.py'), src,
                                    '--robot', ALLOW[robot]], capture_output=True, timeout=180, cwd=DIR)
                outs = [p for p in _glob.glob(os.path.join(tmp, '*')) if not p.endswith('.json')]
                if r.returncode != 0 or not outs:
                    return self._json({'ok': False, 'err': r.stderr.decode('utf-8', 'replace')[-300:]}, 500)
                out = outs[0]
                content = open(out, encoding='utf-8', errors='replace').read()
                return self._json({'ok': True, 'filename': os.path.basename(out), 'content': content,
                                   'lines': len(content.splitlines())})
            except Exception as e:
                return self._json({'ok': False, 'err': str(e)[:300]}, 500)
        # [2026-07-12 CEO] DXF 기준위치·배치를 서버 파일로 영구화 — 어떤 브라우저/새 프로필에서도 동일 복원
        if parsed.path in ('/save-dxf-default', '/save-dxf-layout'):
            fname = 'dxf_default_tf.json' if parsed.path == '/save-dxf-default' else 'dxf_saved_layout.json'
            try:
                length = int(self.headers.get('Content-Length', 0))
                body = self.rfile.read(length).decode('utf-8', errors='replace')
                json.loads(body)  # JSON 검증 (깨진 데이터로 덮어쓰기 방지)
                with open(os.path.join(DIR, fname), 'w', encoding='utf-8') as f:
                    f.write(body)
                print(f'dxf state saved: {fname} ({length}B)')
                self.send_response(200)
                self.send_header('Content-Type', 'text/plain')
                self.end_headers()
                self.wfile.write(b'OK')
            except Exception as e:
                self.send_response(500)
                self.end_headers()
                try:
                    self.wfile.write(str(e).encode('utf-8'))
                except Exception:
                    pass
            return
        if parsed.path != '/save':
            self.send_response(404)
            self.end_headers()
            return
        try:
            length = int(self.headers.get('Content-Length', 0))
            body = self.rfile.read(length).decode('utf-8', errors='replace')
            # body = "data:image/png;base64,...."
            if ',' in body:
                body = body.split(',', 1)[1]
            data = base64.b64decode(body)
            os.makedirs(SAVE_DIR, exist_ok=True)
            fname = f'shot_{time.strftime("%Y%m%d_%H%M%S")}.png'
            path = os.path.join(SAVE_DIR, fname)
            # 초 단위 충돌 방지: 이미 있으면 _1, _2 ... 로 회피
            if os.path.exists(path):
                base = fname[:-4]
                i = 1
                while True:
                    fname = f'{base}_{i}.png'
                    path = os.path.join(SAVE_DIR, fname)
                    if not os.path.exists(path):
                        break
                    i += 1
            with open(path, 'wb') as f:
                f.write(data)
            print(f'shot saved: {path}')
            self.send_response(200)
            self.send_header('Content-Type', 'text/plain; charset=utf-8')
            self.send_header('Content-Length', len(fname.encode('utf-8')))
            self.end_headers()
            self.wfile.write(fname.encode('utf-8'))
        except Exception as e:
            self.send_response(500)
            self.end_headers()
            try:
                self.wfile.write(str(e).encode('utf-8'))
            except Exception:
                pass

    def do_GET(self):
        parsed = urllib.parse.urlparse(self.path)
        if parsed.path == '/api/list-jbi':
            self._json(list_jbi_files())
            return
        if parsed.path == '/api/browse':
            qs = urllib.parse.parse_qs(parsed.query)
            path = qs.get('path', ['E:\\'])[0]
            if not os.path.isdir(path):
                self._json({'error': 'not a directory'}, 400)
                return
            try:
                entries = os.listdir(path)
                dirs, files = [], []
                for e in sorted(entries):
                    full = os.path.join(path, e)
                    if os.path.isdir(full):
                        dirs.append({'name': e, 'path': full})
                    elif e.upper().endswith('.JBI'):
                        files.append({'name': e, 'path': full})
                self._json({'path': path, 'parent': os.path.dirname(path), 'dirs': dirs, 'files': files})
            except Exception as e:
                self._json({'error': str(e)}, 500)
            return
        if parsed.path == '/api/engine-basis':
            qs = urllib.parse.parse_qs(parsed.query)
            path = qs.get('path', [''])[0]
            if not path or not os.path.isfile(path):
                self._json({'error': 'file not found'}, 404)
                return
            self._json(engine_basis(path))
            return
        if parsed.path == '/api/weld-photos':
            qs = urllib.parse.parse_qs(parsed.query)
            path = qs.get('jbi', [''])[0]
            name = qs.get('name', [''])[0]
            if not path and not name:
                self._json({'error': 'no jbi'}, 400)
                return
            self._json(weld_photos(path, name))
            return
        if parsed.path == '/api/photo':
            qs = urllib.parse.parse_qs(parsed.query)
            p = qs.get('path', [''])[0]
            ext = os.path.splitext(p)[1].lower()
            # 경로 안전: E: 드라이브 + 이미지 확장자만
            if not p or ext not in _IMG_EXT or not os.path.isfile(p) or not os.path.abspath(p).upper().startswith('E:'):
                self.send_response(404); self.end_headers(); return
            ct = {'.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.png': 'image/png',
                  '.gif': 'image/gif', '.bmp': 'image/bmp', '.webp': 'image/webp'}.get(ext, 'application/octet-stream')
            try:
                w = 0
                try:
                    w = int(qs.get('w', ['0'])[0])
                except Exception:
                    w = 0
                data = None
                if w and 32 <= w <= 1200:
                    data = _thumb(p, w)          # 격자용 축소 썸네일(빠름)
                    if data is not None:
                        ct = 'image/jpeg'
                if data is None:
                    with open(p, 'rb') as fp:
                        data = fp.read()          # 원본(확대 보기)
                self.send_response(200)
                self.send_header('Content-Type', ct)
                self.send_header('Content-Length', str(len(data)))
                self.end_headers()
                self.wfile.write(data)
            except Exception:
                self.send_response(500); self.end_headers()
            return
        if parsed.path == '/api/parse-jbi':
            qs = urllib.parse.parse_qs(parsed.query)
            path = qs.get('path', [''])[0]
            if not path or not os.path.isfile(path):
                self._json({'error': 'file not found'}, 404)
                return
            try:
                result = parse_jbi(path)
                if result is None:
                    self._json({'error': 'parse failed'}, 500)
                else:
                    self._json(result)
            except Exception as e:
                self._json({'error': str(e)}, 500)
            return
        super().do_GET()

    def _json(self, obj, code=200):
        data = json.dumps(obj, ensure_ascii=False).encode('utf-8')
        self.send_response(code)
        self.send_header('Content-Type', 'application/json; charset=utf-8')
        self.send_header('Content-Length', len(data))
        self.end_headers()
        self.wfile.write(data)

    def end_headers(self):
        self.send_header('Ngrok-Skip-Browser-Warning', '1')
        self.send_header('Bypass-Tunnel-Reminder', '1')
        self.send_header('Access-Control-Allow-Origin', '*')
        self.send_header('Access-Control-Allow-Methods', 'GET, POST, OPTIONS')
        self.send_header('Access-Control-Allow-Headers', 'Content-Type')
        self.send_header('Cache-Control', 'no-store, no-cache, must-revalidate, max-age=0')
        self.send_header('Pragma', 'no-cache')
        self.send_header('Expires', '0')
        super().end_headers()

    def log_message(self, *a):
        pass  # 로그 조용히

if __name__ == '__main__':
    import socketserver, sys
    port = int(sys.argv[1]) if len(sys.argv) > 1 else 8090
    # [2026-07-03] 멀티스레드: 단일 스레드 TCPServer는 동시 접속(브라우저 keep-alive + 헤드리스 캡처)에 막혀 "연결 거부" 발생.
    socketserver.ThreadingTCPServer.allow_reuse_address = True
    socketserver.ThreadingTCPServer.daemon_threads = True
    # [2026-07-18] 보안: 외부(인터넷)에서 8090 접속 시도 발견 → 로컬 전용 바인딩.
    # 8090은 CEO 로컬 확인용(localhost). 태블릿 AR은 별도 8444(dzw_phone_server) 사용.
    with socketserver.ThreadingTCPServer(('127.0.0.1', port), Handler) as srv:
        print(f'DZW5 서버 시작(멀티스레드) → http://localhost:{port}')
        srv.serve_forever()
