"""
캡처 저장 서버 — 포트 8091
브라우저에서 POST /save (body=PNG base64) → screenshots/ 폴더에 저장
"""
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
import base64, os, time, json, uuid, shutil, hmac, secrets
from urllib.parse import urlparse, parse_qs, unquote

WEB_DIR = r'E:\도진팩토리\3D스캔및티칭시스템'

# ── 인터넷 공개용 잠금(HTTP Basic 인증) ────────────────────────────────
#  아이디/비밀번호는 코드에 넣지 않고 fab_auth.json 에서 읽는다(.gitignore 로 git 제외).
#  파일이 없으면 강한 임시 비밀번호를 1회 생성해 만들고 콘솔에 출력한다.
#  ※ 로컬(127.0.0.1) 예외 없음 — Funnel 트래픽도 127.0.0.1 에서 오므로 예외를 두면 잠금이 무효가 된다.
AUTH_FILE = os.path.join(WEB_DIR, 'fab_auth.json')


def _load_auth():
    user = os.environ.get('FAB_USER')
    pw = os.environ.get('FAB_PASS')
    if user and pw:
        print('[잠금] 환경변수(FAB_USER/FAB_PASS)의 아이디·비밀번호를 사용합니다.')
        return user, pw
    if os.path.isfile(AUTH_FILE):
        try:
            with open(AUTH_FILE, 'r', encoding='utf-8') as f:
                cfg = json.load(f)
            user = str(cfg.get('user') or '').strip()
            pw = str(cfg.get('password') or '')
            if user and pw:
                print(f'[잠금] 설정 파일에서 아이디를 읽었습니다: {AUTH_FILE} (아이디={user})')
                return user, pw
            print(f'[잠금] 경고: {AUTH_FILE} 에 user/password 가 비어 있습니다. 새로 만듭니다.')
        except Exception:
            print(f'[잠금] 경고: {AUTH_FILE} 를 읽지 못했습니다. 새로 만듭니다.')
    user = 'dozik'
    pw = secrets.token_urlsafe(12)
    with open(AUTH_FILE, 'w', encoding='utf-8') as f:
        json.dump({'user': user, 'password': pw,
                   '_설명': '제관앱(8091) 인터넷 공개용 아이디/비밀번호. 이 파일을 고치고 서버를 재시작하면 바뀝니다. git 에 올라가지 않습니다.'},
                  f, ensure_ascii=False, indent=1)
    print('=' * 60)
    print('[잠금] 임시 비밀번호를 새로 만들었습니다. 반드시 적어 두세요.')
    print(f'   아이디   : {user}')
    print(f'   비밀번호 : {pw}')
    print(f'   설정파일 : {AUTH_FILE}  (여기서 바꾸고 서버 재시작)')
    print('=' * 60)
    return user, pw


AUTH_USER, AUTH_PASS = _load_auth()
# [CEO 2026-07-22] 아이디에 한글(신성용)을 쓴다. HTTP Basic 인증은 브라우저마다
#   한글을 utf-8 로 보낼 수도, latin-1 로 보낼 수도 있어 한 가지만 비교하면 폰에서 못 들어갈 수 있다.
#   → 허용 토큰을 여러 개 만들어 그중 하나라도 맞으면 통과시킨다(비밀번호는 동일).
#   ASCII 예비 아이디 'ssy' 도 함께 허용 — 한글 입력이 안 되는 상황(현장 폰)의 탈출구.
def _tokens(user, pw):
    out = []
    for u in [user, 'ssy']:
        for enc in ('utf-8', 'latin-1'):
            try:
                out.append(base64.b64encode(f'{u}:{pw}'.encode(enc)).decode('ascii'))
            except Exception:
                pass
    return list(dict.fromkeys(out))


_AUTH_TOKENS = _tokens(AUTH_USER, AUTH_PASS)
_AUTH_TOKEN = _AUTH_TOKENS[0]
PARTS_DIR = os.path.join(WEB_DIR, 'fab_models', 'parts')


def _safe_product_dir(product):
    """묶음 저장 대상 폴더를 안전 검증해 절대경로 반환. 실패 시 None.
    - '..', 슬래시/역슬래시 이탈, 절대경로 차단. 이미 존재하는 폴더만 허용. 원본은 안 건드림."""
    if not product:
        return None
    if '..' in product or '/' in product or '\\' in product or os.path.isabs(product):
        return None
    target = os.path.join(PARTS_DIR, product)
    real = os.path.realpath(target)
    base = os.path.realpath(PARTS_DIR)
    if os.path.commonpath([real, base]) != base:
        return None
    if not os.path.isdir(real):
        return None
    return real

SAVE_DIR = r'E:\도진팩토리\3D스캔및티칭시스템\shots'
os.makedirs(SAVE_DIR, exist_ok=True)

class Handler(SimpleHTTPRequestHandler):
    def __init__(self, *a, **kw):
        super().__init__(*a, directory=WEB_DIR, **kw)

    def end_headers(self):
        # GET 응답에도 캐시 방지 (Ctrl+Shift+R 없이도 최신 코드 반영)
        self.send_header('Cache-Control', 'no-cache')
        super().end_headers()

    # ── 이 요청이 "같은 PC 안에서 온 것"이라는 적극적 증거가 있는가 ──────────
    #  [2026-07-23] 잠금은 인터넷/외부 접속에만 걸고, 같은 PC 안(localhost)에서 여는
    #  화면은 통과시킨다. 8090 으로 연 화면이 8091 저장 API 를 부를 때
    #  브라우저가 다른 출처라서 인증을 안 붙여 401 나던 문제 해결.
    #
    #  ★ 실패-폐쇄(fail-closed): "외부인가?"가 아니라 "로컬이라는 증거가 다 있는가?"로 판단.
    #    아래 3가지가 전부 참일 때만 무인증 통과. 하나라도 아니면 인증 요구.
    #     1) 접속 소켓의 실제 상대주소가 127.0.0.1 / ::1
    #     2) Host 헤더가 localhost / 127.0.0.1 / [::1] (포트 무관)
    #     3) Tailscale 프록시가 붙이는 헤더(X-Forwarded-* / Tailscale-*)가 하나도 없을 것
    #  Tailscale serve·funnel 은 127.0.0.1 에서 들어오므로 1)만으로는 구분이 안 되고,
    #  Host 헤더는 위조 가능하다. 그러나 프록시가 X-Forwarded-For 를 항상 덧붙이므로
    #  외부 요청이 3)을 피할 방법은 없다 → 위조로 뚫리지 않는다.
    #
    #  ★ 검증 범위 (2026-07-23) — 넘겨짚지 않기 위해 그대로 적는다.
    #    실측한 것 : 로컬 200 / tailnet 경유(:10000) 무인증 401 / Host 위조 401 /
    #               로컬+가짜 XFF 401 / tailnet 경유 정상비번 200.
    #    미검증    : 타넷 밖(휴대폰 셀룰러 등) 진짜 공개 Funnel 요청.
    #               → 설계상 같은 프록시를 타므로 동일하게 401 이어야 하지만 눈으로는 못 봤다.
    #               CEO 확인 요망: 폰 와이파이·Tailscale 끄고 :10000 열어 비번창이 뜨는지.
    #
    #  ※ 알려진 한계 — LAN(다른 기기)에서 http://<PC아이피>:8090 으로 열면 저장은 여전히 401.
    #    소켓 상대주소가 127.0.0.1 이 아니라서 '로컬 증거'가 성립하지 않는다(잠금 이전과 동일).
    #    사설망 대역까지 풀면 같은 공유기에 붙은 누구나 도면을 볼 수 있어 임의로 넓히지 않았다.
    #    태블릿·폰은 Funnel 주소(:10000)로 열고 1회 로그인하면 같은 출처라 저장이 된다.
    _PROXY_HINTS = ('x-forwarded-for', 'x-forwarded-host', 'x-forwarded-proto',
                    'forwarded', 'tailscale-user-login', 'tailscale-headers-info')

    def _is_local_request(self):
        try:
            peer = (self.client_address[0] or '').strip()
        except Exception:
            return False
        if peer not in ('127.0.0.1', '::1', '::ffff:127.0.0.1'):
            return False
        host = (self.headers.get('Host') or '').strip().lower()
        hostname = host.rsplit(':', 1)[0] if (':' in host and not host.startswith('[')) else host
        if host.startswith('['):
            hostname = host.split(']', 1)[0] + ']'
        if hostname not in ('localhost', '127.0.0.1', '[::1]'):
            return False
        for h in self._PROXY_HINTS:
            if self.headers.get(h) is not None:
                return False
        return True

    # ── 잠금 검사 ────────────────────────────────────────────────
    def _check_auth(self):
        if self._is_local_request():
            return True
        return self._check_auth_strict()

    def _check_auth_strict(self):
        """인증 통과면 True. 실패면 401 + WWW-Authenticate 를 보내고 False.
        (WWW-Authenticate 가 없으면 브라우저가 비밀번호 창을 띄우지 않는다)"""
        hdr = self.headers.get('Authorization', '')
        if hdr.startswith('Basic '):
            got = hdr[6:].strip()
            for tok in _AUTH_TOKENS:
                if hmac.compare_digest(got, tok):
                    return True
        self.send_response(401)
        self.send_header('WWW-Authenticate', 'Basic realm="Dozik Fab", charset="UTF-8"')
        self.send_header('Content-Type', 'text/plain; charset=utf-8')
        self.end_headers()
        try:
            self.wfile.write('401 인증 필요 (아이디/비밀번호)\n'.encode('utf-8'))
        except Exception:
            pass
        return False

    def do_GET(self):
        if not self._check_auth():
            return
        return super().do_GET()

    def do_HEAD(self):
        if not self._check_auth():
            return
        return super().do_HEAD()

    def do_OPTIONS(self):
        # 프리플라이트는 자격증명을 싣지 않으므로 열어 둔다(데이터 노출 없음).
        self.send_response(200)
        self._cors()
        self.end_headers()

    def do_POST(self):
        if not self._check_auth():
            return
        # ── 묶음(grouping.json) 저장 — /save 보다 먼저 분기(base64 로직과 충돌 방지) ──
        _p = urlparse(self.path).path
        if _p == '/grouping':
            return self._do_grouping()
        # ── 어셈블리 사진주석: 원본사진 파일저장 / 메타(photo_notes.json) 저장 ──
        if _p == '/save-photo':
            return self._do_save_photo()
        if _p == '/save-photonotes':
            return self._do_save_photonotes()
        if _p == '/save-pinnotes':
            return self._do_save_pinnotes()
        if _p == '/save-viewstate':
            return self._do_save_viewstate()
        try:
            length = int(self.headers.get('Content-Length', 0))
            body = self.rfile.read(length).decode()
            # body = "data:image/png;base64,...."
            if ',' in body:
                body = body.split(',', 1)[1]
            data = base64.b64decode(body)
            fname = f'shot_{time.strftime("%Y%m%d_%H%M%S")}.png'
            path = os.path.join(SAVE_DIR, fname)
            with open(path, 'wb') as f:
                f.write(data)
            print(f'저장: {path}')
            self.send_response(200)
            self._cors()
            self.send_header('Content-Type', 'text/plain')
            self.end_headers()
            self.wfile.write(fname.encode())
        except Exception:
            import traceback
            traceback.print_exc()
            try:
                self.send_response(500)
                self._cors()
                self.end_headers()
            except Exception:
                pass

    def _do_grouping(self):
        try:
            length = int(self.headers.get('Content-Length', 0))
            raw = self.rfile.read(length).decode('utf-8')
            payload = json.loads(raw)
            qs = parse_qs(urlparse(self.path).query)
            product = unquote(qs.get('product', [''])[0]) if qs.get('product') else payload.get('product', '')
            target = _safe_product_dir(product)
            if target is None:
                self.send_response(400); self._cors()
                self.send_header('Content-Type', 'application/json'); self.end_headers()
                self.wfile.write(json.dumps({'ok': False, 'error': 'invalid product'}).encode())
                return
            groups = payload.get('groups', {})
            out = os.path.join(target, 'grouping.json')
            with open(out, 'w', encoding='utf-8') as f:
                json.dump({'product': product, 'groups': groups}, f, ensure_ascii=False, indent=1)
            print(f'묶음 저장: {out}')
            self.send_response(200); self._cors()
            self.send_header('Content-Type', 'application/json'); self.end_headers()
            self.wfile.write(json.dumps({'ok': True}).encode())
        except Exception:
            import traceback
            traceback.print_exc()
            try:
                self.send_response(500); self._cors()
                self.send_header('Content-Type', 'application/json'); self.end_headers()
                self.wfile.write(json.dumps({'ok': False, 'error': 'server error'}).encode())
            except Exception:
                pass

    # ── 어셈블리 사진주석 ─────────────────────────────────────────────
    _IMG_EXT = {'image/png': 'png', 'image/jpeg': 'jpg', 'image/jpg': 'jpg',
                'image/webp': 'webp', 'image/gif': 'gif'}

    def _read_json(self):
        length = int(self.headers.get('Content-Length', 0))
        return json.loads(self.rfile.read(length).decode('utf-8'))

    def _product_from(self, payload):
        qs = parse_qs(urlparse(self.path).query)
        if qs.get('model'):
            return unquote(qs.get('model', [''])[0])
        return payload.get('product', '')

    def _reply(self, code, obj):
        self.send_response(code); self._cors()
        self.send_header('Content-Type', 'application/json'); self.end_headers()
        self.wfile.write(json.dumps(obj, ensure_ascii=False).encode())

    def _do_save_photo(self):
        """원본 사진을 <제품>/photos/ 에 저장. 저장 파일명은 서버가 생성(클라 파일명 신뢰 안 함)."""
        try:
            payload = self._read_json()
            product = self._product_from(payload)
            target = _safe_product_dir(product)
            if target is None:
                return self._reply(400, {'ok': False, 'error': 'invalid product'})
            data_url = payload.get('data', '')
            # data:image/png;base64,....  → mime 로만 확장자 결정(클라 filename 은 경로로 안 씀)
            ext = 'png'
            if data_url.startswith('data:') and ';base64,' in data_url:
                mime = data_url[5:data_url.index(';')].lower()
                if mime not in self._IMG_EXT:
                    return self._reply(400, {'ok': False, 'error': 'unsupported image type'})
                ext = self._IMG_EXT[mime]
                raw = data_url.split(',', 1)[1]
            elif ',' in data_url:
                raw = data_url.split(',', 1)[1]
            else:
                raw = data_url
            img = base64.b64decode(raw)
            photos_dir = os.path.join(target, 'photos')
            os.makedirs(photos_dir, exist_ok=True)
            fname = 'photo_%s_%s.%s' % (time.strftime('%Y%m%d_%H%M%S'), uuid.uuid4().hex[:6], ext)
            out = os.path.join(photos_dir, fname)
            # 경로이탈 2차 방어: 최종 경로가 photos_dir 안인지 재확인
            if os.path.commonpath([os.path.realpath(out), os.path.realpath(photos_dir)]) != os.path.realpath(photos_dir):
                return self._reply(400, {'ok': False, 'error': 'bad path'})
            with open(out, 'wb') as f:
                f.write(img)
            print(f'사진 저장: {out} ({len(img)} bytes)')
            return self._reply(200, {'ok': True, 'file': fname})
        except Exception:
            import traceback; traceback.print_exc()
            try: self._reply(500, {'ok': False, 'error': 'server error'})
            except Exception: pass

    def _do_save_photonotes(self):
        """어셈블리 사진주석 메타(photo_notes.json) 저장. 원본 parts.json·grouping.json 무수정."""
        try:
            payload = self._read_json()
            product = self._product_from(payload)
            target = _safe_product_dir(product)
            if target is None:
                return self._reply(400, {'ok': False, 'error': 'invalid product'})
            assemblies = payload.get('assemblies', {})
            out = os.path.join(target, 'photo_notes.json')
            with open(out, 'w', encoding='utf-8') as f:
                json.dump({'product': product, 'assemblies': assemblies}, f, ensure_ascii=False, indent=1)
            print(f'사진주석 저장: {out}')
            return self._reply(200, {'ok': True})
        except Exception:
            import traceback; traceback.print_exc()
            try: self._reply(500, {'ok': False, 'error': 'server error'})
            except Exception: pass

    def _do_save_pinnotes(self):
        """3D 표면 핀+사진주석 메타(pin_notes.json) 저장. 원본·photo_notes.json 무수정."""
        try:
            payload = self._read_json()
            product = self._product_from(payload)
            target = _safe_product_dir(product)
            if target is None:
                return self._reply(400, {'ok': False, 'error': 'invalid product'})
            pins = payload.get('pins', [])
            out = os.path.join(target, 'pin_notes.json')
            # [데이터 손실 금지 · 2026-07-22] 덮어쓰기 전에 항상 직전 본을 백업해 둔다.
            #   (검증 스크립트가 실수로 이 엔드포인트를 때려 CEO 핀이 지워진 사고가 있었다.
            #    되돌릴 수 있어야 한다 — 하루 1개씩만 남겨 파일이 무한히 늘지 않게 한다.)
            try:
                if os.path.exists(out):
                    bak = out + '.bak_' + time.strftime('%Y%m%d')
                    if not os.path.exists(bak):
                        shutil.copy2(out, bak)
                    # 직전본은 항상 갱신(같은 날 여러 번 저장해도 '바로 전' 상태는 남는다)
                    shutil.copy2(out, out + '.prev')
            except Exception:
                pass
            with open(out, 'w', encoding='utf-8') as f:
                json.dump({'product': product, 'pins': pins}, f, ensure_ascii=False, indent=1)
            print(f'핀주석 저장: {out}')
            return self._reply(200, {'ok': True})
        except Exception:
            import traceback; traceback.print_exc()
            try: self._reply(500, {'ok': False, 'error': 'server error'})
            except Exception: pass

    @staticmethod
    def _num_list(v, n):
        """길이 n 의 유한 실수 배열이면 float 리스트로, 아니면 None."""
        if not isinstance(v, list) or len(v) != n:
            return None
        out = []
        for x in v:
            if isinstance(x, bool) or not isinstance(x, (int, float)):
                return None
            f = float(x)
            if f != f or f in (float('inf'), float('-inf')):
                return None
            out.append(f)
        return out

    def _do_save_viewstate(self):
        """제품 표시 자세(루트 그룹 변환) 오버레이 view_state.json 저장/삭제.
        - 원본(parts.json/OBJ/STEP/index.json) 무수정. 이 파일 하나만 쓰고 지운다.
        - 로봇 자세·펄스↔각도·TCP·IK/JBI 와 무관한 '화면 표시 자세' 전용.
        - {'clear': true} 면 오버레이 파일만 삭제(원본 무관)."""
        try:
            payload = self._read_json()
            product = self._product_from(payload)
            target = _safe_product_dir(product)
            if target is None:
                return self._reply(400, {'ok': False, 'error': 'invalid product'})
            out = os.path.join(target, 'view_state.json')
            # 경로이탈 2차 방어: 최종 경로가 제품 폴더 안인지 재확인
            if os.path.commonpath([os.path.realpath(out), os.path.realpath(target)]) != os.path.realpath(target):
                return self._reply(400, {'ok': False, 'error': 'bad path'})
            if payload.get('clear'):
                existed = os.path.isfile(out)
                if existed:
                    os.remove(out)
                print(f'자세 초기화: {out} (있었음={existed})')
                return self._reply(200, {'ok': True, 'cleared': True, 'existed': existed})
            view = payload.get('view') or {}
            pos = self._num_list(view.get('position'), 3)
            quat = self._num_list(view.get('quaternion'), 4)
            scl = self._num_list(view.get('scale'), 3)
            if pos is None or quat is None or scl is None:
                return self._reply(400, {'ok': False, 'error': 'invalid view'})
            with open(out, 'w', encoding='utf-8') as f:
                json.dump({'product': product, 'saved': time.strftime('%Y-%m-%d %H:%M:%S'),
                           'view': {'position': pos, 'quaternion': quat, 'scale': scl}},
                          f, ensure_ascii=False, indent=1)
            print(f'자세 저장: {out}')
            return self._reply(200, {'ok': True})
        except Exception:
            import traceback; traceback.print_exc()
            try: self._reply(500, {'ok': False, 'error': 'server error'})
            except Exception: pass

    def _cors(self):
        self.send_header('Access-Control-Allow-Origin', '*')
        self.send_header('Access-Control-Allow-Headers', 'Content-Type')
        self.send_header('Access-Control-Allow-Methods', 'POST, OPTIONS')

    def log_message(self, *a): pass  # 조용히

print('캡처 서버 시작 → http://localhost:8091  (screenshots/ 폴더에 저장)')
server = ThreadingHTTPServer(('0.0.0.0', 8091), Handler)
server.daemon_threads = True
server.serve_forever()
