# -*- coding: utf-8 -*-
"""Uncle Labs CNC desktop client. Python 2.7.18 / 3.4+ with Tkinter.

Standalone: copy this file to the machine and run python gcode_client.py.
Uses Desktop API keys, verified HTTPS, and standard-library modules only.
No CNC connection is opened and no downloaded program is executed.
"""
from __future__ import print_function, unicode_literals
import base64
import hashlib
import io
import json
import os
import re
import ssl
import sys
import tempfile
import threading
import uuid
from email.message import Message
try:
    text_type = unicode
except NameError:
    text_type = str
try:
    from urllib.request import Request, build_opener, HTTPRedirectHandler, HTTPSHandler
    from urllib.error import HTTPError
    from urllib.parse import urlsplit, urlencode
    import queue
except ImportError:
    from urllib2 import Request, build_opener, HTTPRedirectHandler, HTTPSHandler, HTTPError
    from urlparse import urlsplit
    from urllib import urlencode
    import Queue as queue
try:
    import tkinter as tk
    from tkinter import ttk, messagebox, filedialog
except ImportError:
    import Tkinter as tk
    import ttk
    import tkMessageBox as messagebox
    import tkFileDialog as filedialog

APP_TITLE = 'Uncle Labs CNC - Desktop'
MAX_RESPONSE = 32 * 1024 * 1024
CONFIG_DIR = os.path.join(os.environ.get('APPDATA') or os.path.expanduser('~'), 'UncleLabsCNC') if os.name == 'nt' else os.path.join(os.environ.get('XDG_CONFIG_HOME') or os.path.expanduser('~/.config'), 'unclelabs-cnc')
CONFIG_FILE = os.path.join(CONFIG_DIR, 'client.json')
DEFAULTS = {'server_url':'https://unclelabs-cnc.com', 'account':'', 'api_key':'',
            'remember_key':False, 'language':'en', 'download_dir':os.path.expanduser('~'), 'ca_file':''}
WORDS = {
    'settings':('Settings / Account', 'ตั้งค่า / บัญชี (Settings)'),
    'language':('Language', 'ภาษา (Language)'), 'file':('File', 'ไฟล์ (File)'),
    'refresh':('Refresh', 'รีเฟรช (Refresh)'), 'quit':('Exit', 'ออก (Exit)'),
    'server':('Server URL', 'ที่อยู่ Server URL'), 'account':('Account label (optional)', 'ชื่อบัญชีสำหรับจำ (ไม่บังคับ)'),
    'key':('Desktop API key', 'Desktop API key'), 'remember':('Remember key on this computer', 'จำ API key ในเครื่องนี้'),
    'remember_hint':('Saved keys are stored locally, not encrypted. Leave unchecked on shared computers.', 'Key ที่จำจะเก็บในเครื่องโดยไม่เข้ารหัส ไม่ควรจำบนเครื่องที่ใช้ร่วมกัน'),
    'folder':('Download folder', 'โฟลเดอร์ดาวน์โหลด (Download folder)'),
    'ca':('CA certificate file (optional)', 'ไฟล์ใบรับรอง CA (ไม่บังคับ)'),
    'test':('Test connection', 'ทดสอบการเชื่อมต่อ (Test)'), 'save':('Save', 'บันทึก (Save)'),
    'cancel':('Cancel', 'ยกเลิก (Cancel)'), 'browse':('Browse...', 'เลือก (Browse)...'),
    'setup_hint':('Open the website > Account > Desktop API keys. Create a key and paste it here. A new key switches accounts.', 'เปิดเว็บ > บัญชี > Desktop API keys แล้วสร้าง key มาวาง เปลี่ยนบัญชีโดยใช้ key ของบัญชีใหม่'),
    'search':('Search filename', 'ค้นหาชื่อไฟล์ (Search)'), 'source':('File source', 'ที่มาไฟล์ (Source)'),
    'exports':('Web exports', 'ไฟล์ส่งออกจากเว็บ (Exports)'), 'imports':('Imported NC', 'ไฟล์ NC นำเข้า (Imports)'),
    'all':('All account jobs', 'งานทั้งหมดในบัญชี (All jobs)'),
    'projects':('Saved projects', 'โปรเจกต์ที่บันทึกไว้ (Projects)'),
    'review_project':('Generate NC from the latest saved project, using its saved controller? Confirm you have checked tools, work origin, clearance and tool changes.', 'สร้าง NC จากโปรเจกต์ที่บันทึกล่าสุดตามคอนโทรลเลอร์ที่ตั้งไว้ ยืนยันว่าตรวจทูล ศูนย์งาน ระยะหลบ และขั้นตอนเปลี่ยนทูลแล้ว'),
    'server_update':('The server does not yet provide saved projects. Update the website with the matching client release, then retry. Web exports and Imported NC remain available in File source.', 'เซิร์ฟเวอร์ยังไม่มี API รายการโปรเจกต์ ให้อัปเดตเว็บพร้อม Client แล้วลองอีกครั้ง ระหว่างนี้เลือก Exports หรือ Imports ในช่องที่มาไฟล์ได้'),
    'load':('Open / Preview', 'เปิดดู (Preview)'), 'download':('Download NC / file', 'ดาวน์โหลด NC / ไฟล์ (Download)'),
    'edited':('Upload edited copy', 'อัปโหลดฉบับแก้ไข (New copy)'),
    'upload':('Upload NC...', 'อัปโหลด NC (Upload)...'), 'parameters':('Operation parameters', 'พารามิเตอร์งาน (Parameters)'),
    'ready':('Ready', 'พร้อมใช้งาน (Ready)'), 'working':('Connecting...', 'กำลังเชื่อมต่อ (Connecting)...'),
    'select':('Select a job or file first.', 'กรุณาเลือกงานหรือไฟล์ก่อน'), 'empty':('No matching jobs in this source. Save a project on the website or select All account jobs.', 'ไม่พบงานในรายการนี้ ให้บันทึกโปรเจกต์บนเว็บหรือเลือกงานทั้งหมดในบัญชี'),
    'unsaved':('Discard unsaved editor changes?', 'ละทิ้งการแก้ไขที่ยังไม่บันทึกหรือไม่?'),
    'copied':('Uploaded as a new file. Original unchanged.', 'อัปโหลดเป็นไฟล์ใหม่แล้ว ต้นฉบับยังอยู่'),
    'binary':('Binary archive or PDF: download to view with the appropriate application.', 'ไฟล์ ZIP หรือ PDF: ดาวน์โหลดแล้วเปิดด้วยโปรแกรมที่รองรับ'),
    'limit':('File must contain 1 byte to 1 MiB.', 'ไฟล์ต้องมีขนาด 1 byte ถึง 1 MiB'),
    'invalid':('Enter a complete http(s) server URL and a valid Desktop API key.', 'กรอก URL แบบ http(s) และ Desktop API key ให้ครบ'),
    'tls':('TLS connection failed. Check date/time and CA certificates. Legacy PCs need a compatible TLS runtime or a local transfer gateway; certificate checks remain enabled.', 'เชื่อมต่อ TLS ไม่สำเร็จ ตรวจเวลาเครื่องและ CA เครื่องเก่าต้องใช้ runtime TLS ที่รองรับหรือตัวกลางใน LAN ระบบยังตรวจใบรับรองเสมอ'),
    'network':('Cannot connect. Check server URL and network.', 'เชื่อมต่อไม่ได้ ตรวจ URL และเครือข่าย'),
    '401':('API key is invalid or revoked. Open Settings to change account/key.', 'API key ไม่ถูกต้องหรือถูกเพิกถอน เปลี่ยนได้ใน Settings'),
    '403':('This account/license does not allow this action. Check Desktop API and operation permissions.', 'บัญชี/License ไม่อนุญาต ตรวจสิทธิ์ Desktop API และ operation'),
    '404':('File or API endpoint not found. Refresh the list and check the server version.', 'ไม่พบไฟล์หรือ API รีเฟรชรายการและตรวจเวอร์ชันเซิร์ฟเวอร์'),
    'redirect':('Server redirected the request. Enter the final HTTPS server URL in Settings.', 'Server เปลี่ยน URL ให้ใส่ URL HTTPS ปลายทางใน Settings'),
    'downloaded':('Saved:', 'บันทึกไฟล์แล้ว:'), 'connected':('Connected account:', 'เชื่อมต่อบัญชี:'),
    'new_copy':('Upload this edit as a new NC file for review and simulation?', 'อัปโหลดฉบับแก้ไขเป็นไฟล์ NC ใหม่เพื่อตรวจและจำลองหรือไม่?'),
}


def tr(cfg, key):
    pair = WORDS.get(key, (key, key))
    return pair[1 if cfg.get('language') == 'th' else 0]


def load_config(path=None):
    cfg = dict(DEFAULTS)
    try:
        with io.open(path or CONFIG_FILE, 'r', encoding='utf-8') as handle:
            data = json.load(handle)
        if isinstance(data, dict):
            cfg.update(dict((k, data[k]) for k in DEFAULTS if k in data))
    except (ValueError, IOError, OSError):
        # Import only the old server address; there was no account key in v1.
        old = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'gcode_client_config.json')
        if path is None and os.path.isfile(old):
            try:
                with io.open(old, 'r', encoding='utf-8') as handle:
                    legacy = json.load(handle)
                cfg['server_url'] = 'http://%s:%s' % (legacy['host'], legacy.get('port', '8000'))
            except (KeyError, TypeError, ValueError, IOError):
                pass
    if not cfg.get('remember_key'):
        cfg['api_key'] = ''
    if cfg.get('language') not in ('en', 'th'):
        cfg['language'] = 'en'
    return cfg


def save_config(cfg, path=None):
    path = os.path.abspath(path or CONFIG_FILE)
    folder = os.path.dirname(path)
    if not os.path.isdir(folder):
        os.makedirs(folder, 0o700)
    stored = dict((k, cfg.get(k, v)) for k, v in DEFAULTS.items())
    if not stored['remember_key']:
        stored['api_key'] = ''
    fd, temporary = tempfile.mkstemp(prefix='client-', suffix='.tmp', dir=folder)
    try:
        with os.fdopen(fd, 'wb') as handle:
            handle.write(json.dumps(stored, ensure_ascii=True, indent=2).encode('utf-8'))
        if os.name == 'nt':
            import ctypes
            move = ctypes.windll.kernel32.MoveFileExW
            move.argtypes = [ctypes.c_wchar_p, ctypes.c_wchar_p, ctypes.c_ulong]
            move.restype = ctypes.c_int
            if not move(temporary, path, 1 | 8):
                raise ctypes.WinError()
        else:
            os.rename(temporary, path)
            os.chmod(path, 0o600)
    finally:
        if os.path.exists(temporary):
            os.unlink(temporary)


class ApiError(Exception):
    pass


class NoRedirects(HTTPRedirectHandler):
    def redirect_request(self, req, fp, code, msg, headers, newurl):
        return None


def safe_name(name):
    name = name.replace('\\', '/').split('/')[-1]
    name = re.sub(r'[\x00-\x1f<>:"/\\|?*]', '_', name).strip(' .')
    if not name or name.split('.')[0].upper() in ('CON','PRN','AUX','NUL') or re.match(r'^(COM|LPT)[0-9](\.|$)', name, re.I):
        name = 'program_' + (name or 'download.nc')
    return name


class DesktopAPI(object):
    def __init__(self, cfg):
        self.cfg = dict(cfg)
        self.url = cfg.get('server_url', '').strip().rstrip('/')
        parts = urlsplit(self.url)
        key = cfg.get('api_key', '').strip()
        if parts.scheme not in ('https','http') or not parts.hostname or parts.username or parts.password or parts.path or parts.query or parts.fragment or not re.match(r'^[A-Za-z0-9_-]{32,200}$', key):
            raise ApiError(tr(cfg, 'invalid'))
        try:
            parts.port
        except ValueError:
            raise ApiError(tr(cfg, 'invalid'))
        handlers = [NoRedirects()]
        if parts.scheme == 'https':
            if not hasattr(ssl, 'create_default_context'):
                raise ApiError(tr(cfg, 'tls'))
            context = ssl.create_default_context(cafile=cfg.get('ca_file') or None)
            handlers.append(HTTPSHandler(context=context))
        self.key = key
        self.opener = build_opener(*handlers)

    def request(self, path, payload=None, binary=False):
        if not path.startswith('/api/v1/') or '\r' in path or '\n' in path:
            raise ApiError('Invalid API path')
        request = Request(self.url + path, data=json.dumps(payload, ensure_ascii=True).encode('utf-8') if payload is not None else None,
                          headers={'X-API-Key':self.key, 'Accept':'application/json', 'Content-Type':'application/json'})
        try:
            response = self.opener.open(request, timeout=30)
            try:
                body = response.read(MAX_RESPONSE + 1)
                headers = response.info()
            finally:
                response.close()
            if len(body) > MAX_RESPONSE:
                raise ApiError('Response exceeds 32 MiB')
            if binary:
                expected = headers.get('X-Content-SHA256')
                if expected and hashlib.sha256(body).hexdigest() != expected:
                    raise ApiError('Checksum mismatch; download rejected')
                return body, headers
            return json.loads(body.decode('utf-8'))
        except HTTPError as exc:
            try:
                detail = json.loads(exc.read(8192).decode('utf-8')).get('error', '')
            except (ValueError, UnicodeError):
                detail = ''
            finally:
                exc.close()
            key = 'redirect' if 300 <= exc.code < 400 else str(exc.code)
            message = tr(self.cfg, key) if key in WORDS else 'HTTP %s' % exc.code
            if detail and (self.cfg.get('language') == 'th' or not re.search('[\u0e00-\u0e7f]', detail)):
                message += '\n' + detail
            error = ApiError(message.replace(self.key, '[key]'))
            error.status = exc.code
            raise error
        except ApiError:
            raise
        except Exception as exc:
            if 'SSL' in text_type(exc).upper() or 'CERTIFICATE' in text_type(exc).upper():
                raise ApiError(tr(self.cfg, 'tls'))
            raise ApiError(tr(self.cfg, 'network'))

    def capabilities(self):
        return self.request('/api/v1/capabilities/')

    def list_files(self, source):
        if source == 'all':
            rows = []
            for kind in ('projects','exports','imports'):
                rows.extend(self.list_files(kind))
            return sorted(rows,key=lambda item:item.get('updated_at',item.get('created_at','')),reverse=True)
        endpoint = {'exports':'exports','imports':'files','projects':'projects'}[source]
        offset, rows = 0, []
        while True:
            try:
                page = self.request('/api/v1/%s/?%s' % (endpoint, urlencode({'offset':offset, 'limit':100})))
            except ApiError as exc:
                if source == 'projects' and getattr(exc,'status',None) == 404:
                    raise ApiError(tr(self.cfg,'server_update'))
                raise
            batch = page.get('files', [])
            for item in batch:
                item['source'] = source
            rows.extend(batch)
            offset += len(batch)
            if not batch or offset >= page.get('total', offset):
                return rows

    def download(self, item, reviewed=False):
        if item['source'] == 'projects':
            if not reviewed:raise ApiError(tr(self.cfg,'review_project'))
            path = '/api/v1/projects/%d/download/?reviewed=true' % int(item['id'])
        elif item['source'] == 'exports':
            path = '/api/v1/exports/%d/download/' % int(item['id'])
        else:
            path = '/api/v1/files/%s/download/?mode=original' % uuid.UUID(str(item['id']))
        body, headers = self.request(path, binary=True)
        message = Message()
        message['Content-Disposition'] = headers.get('Content-Disposition', '')
        return {'content':body, 'filename':safe_name(message.get_filename() or item['filename']),
                'binary':headers.get('Content-Type', '').split(';')[0] in ('application/pdf','application/zip') or body.startswith((b'%PDF-',b'PK\x03\x04'))}

    def upload(self, filename, content):
        if not content or len(content) > 1048576:
            raise ApiError(tr(self.cfg, 'limit'))
        result = self.request('/api/v1/files/', {'filename':safe_name(filename), 'content_base64':base64.b64encode(content).decode('ascii')})
        if result.get('sha256') != hashlib.sha256(content).hexdigest():
            raise ApiError('Upload checksum mismatch')
        return result

    def parameters(self, item):
        if item['source'] == 'projects':
            return self.request('/api/v1/projects/%d/job/' % int(item['id']))['job']
        if item['source'] == 'exports':
            return {'File':item['filename'], 'Source':'Saved web export; operation parameters are available in its web project.'}
        return self.request('/api/v1/files/%s/job/' % uuid.UUID(str(item['id'])))['job']


def atomic_download(path, content):
    """Caller has chosen/confirmed the path. Leave any old file intact on write errors."""
    folder = os.path.dirname(os.path.abspath(path))
    fd, temporary = tempfile.mkstemp(prefix='.cnc-', dir=folder)
    try:
        with os.fdopen(fd, 'wb') as handle:
            handle.write(content)
        if os.name == 'nt':
            import ctypes
            move = ctypes.windll.kernel32.MoveFileExW
            move.argtypes = [ctypes.c_wchar_p, ctypes.c_wchar_p, ctypes.c_ulong]
            if not move(temporary, os.path.abspath(path), 1 | 8):
                raise ctypes.WinError()
        else:
            os.rename(temporary, path)
    finally:
        if os.path.exists(temporary):
            os.unlink(temporary)


class SettingsDialog(tk.Toplevel):
    def __init__(self, app):
        tk.Toplevel.__init__(self, app)
        self.app = app
        self.title(tr(app.cfg, 'settings'))
        self.transient(app)
        self.grab_set()
        self.columnconfigure(1, weight=1)
        self.variables = {}
        tk.Label(self, text=tr(app.cfg, 'setup_hint'), wraplength=560, justify='left').grid(row=0, column=0, columnspan=3, padx=14, pady=12)
        for row, (key, label) in enumerate([('server_url','server'),('account','account'),('api_key','key'),('download_dir','folder'),('ca_file','ca')], 1):
            tk.Label(self, text=tr(app.cfg, label)).grid(row=row, column=0, sticky='w', padx=12, pady=7)
            variable = tk.StringVar(value=app.cfg.get(key, ''))
            self.variables[key] = variable
            tk.Entry(self, textvariable=variable, width=43, show='*' if key == 'api_key' else '').grid(row=row, column=1, sticky='ew', padx=8)
            if key in ('download_dir','ca_file'):
                tk.Button(self, text=tr(app.cfg, 'browse'), command=lambda k=key:self.browse(k)).grid(row=row, column=2)
        self.remember = tk.BooleanVar(value=app.cfg.get('remember_key', False))
        tk.Checkbutton(self, text=tr(app.cfg,'remember'), variable=self.remember).grid(row=6, column=0, columnspan=3, sticky='w', padx=12)
        tk.Label(self, text=tr(app.cfg,'remember_hint'), wraplength=560, justify='left').grid(row=7, column=0, columnspan=3, padx=12, pady=6)
        controls = tk.Frame(self)
        controls.grid(row=8, column=0, columnspan=3, pady=12)
        for label, command in [('test',self.test),('save',self.save),('cancel',self.destroy)]:
            tk.Button(controls, text=tr(app.cfg,label), command=command).pack(side='left', padx=8)

    def browse(self, key):
        value = filedialog.askdirectory(parent=self) if key == 'download_dir' else filedialog.askopenfilename(parent=self)
        if value:
            self.variables[key].set(value)

    def config_values(self):
        cfg = dict(self.app.cfg)
        cfg.update(dict((k,v.get().strip()) for k,v in self.variables.items()))
        cfg['remember_key'] = bool(self.remember.get())
        return cfg

    def test(self):
        cfg = self.config_values()
        self.app.run_task(lambda:DesktopAPI(cfg).capabilities(), lambda data:messagebox.showinfo(APP_TITLE,tr(cfg,'connected')+' '+data.get('account','API v1')), cfg)

    def save(self):
        if self.app.busy:
            return
        try:
            cfg = self.config_values()
            DesktopAPI(cfg)
            save_config(cfg)
        except Exception as exc:
            messagebox.showerror(APP_TITLE, text_type(exc), parent=self)
            return
        self.destroy()
        self.app.apply_config(cfg)


class GCodeClientApp(tk.Tk):
    def __init__(self):
        tk.Tk.__init__(self)
        self.title(APP_TITLE)
        self.geometry('1000x700')
        self.minsize(760, 520)
        self.option_add('*Font', ('DejaVu Sans' if os.name != 'nt' else 'Tahoma', 11))
        self.cfg = load_config()
        self.events = queue.Queue()
        self.busy = False
        self.generation = 0
        self.programs = []
        self.loaded = None
        self.original_text = ''
        self.encoding = 'utf-8'
        self.source = 'all'
        self.build()
        self.protocol('WM_DELETE_WINDOW', self.quit_app)
        self.bind('<F5>', lambda event:self.refresh_list())
        self.after(100, self.poll)
        self.after(150, self.refresh_list if self.cfg.get('api_key') else self.open_settings)

    def build(self):
        bar = tk.Menu(self)
        file_menu = tk.Menu(bar, tearoff=False)
        file_menu.add_command(label=tr(self.cfg,'refresh')+' (F5)', command=self.refresh_list)
        file_menu.add_command(label=tr(self.cfg,'quit'), command=self.quit_app)
        bar.add_cascade(label=tr(self.cfg,'file'), menu=file_menu)
        bar.add_command(label=tr(self.cfg,'settings'), command=self.open_settings)
        language_menu = tk.Menu(bar, tearoff=False)
        language_menu.add_command(label='English', command=lambda:self.change_language('en'))
        language_menu.add_command(label='Thai / English', command=lambda:self.change_language('th'))
        bar.add_cascade(label='Language' if self.cfg.get('language') == 'en' else 'ภาษา / Language', menu=language_menu)
        self.config(menu=bar)
        self.content = tk.Frame(self)
        self.content.pack(fill='both', expand=True, padx=10, pady=8)
        top = tk.Frame(self.content)
        top.pack(fill='x')
        tk.Label(top, text=tr(self.cfg,'search')).pack(side='left')
        self.search = tk.StringVar()
        entry = tk.Entry(top, textvariable=self.search, width=22)
        entry.pack(side='left', padx=6)
        entry.bind('<KeyRelease>', lambda event:self.filter_list())
        self.source_var = tk.StringVar(value=tr(self.cfg,self.source))
        sources = ttk.Combobox(top, textvariable=self.source_var, state='readonly', width=26, values=[tr(self.cfg,kind) for kind in ('all','projects','exports','imports')])
        sources.pack(side='left', padx=6)
        sources.bind('<<ComboboxSelected>>', self.select_source)
        tk.Button(top, text=tr(self.cfg,'refresh'), command=self.refresh_list).pack(side='left')
        self.account_label = tk.Label(self.content, anchor='w', text=self.cfg['server_url']+' | '+self.cfg.get('account',''))
        self.account_label.pack(fill='x', pady=6)
        pane = tk.PanedWindow(self.content, orient='horizontal', sashwidth=6)
        pane.pack(fill='both', expand=True)
        left = tk.Frame(pane)
        scroll = tk.Scrollbar(left)
        self.file_list = tk.Listbox(left, exportselection=False, yscrollcommand=scroll.set)
        scroll.config(command=self.file_list.yview)
        scroll.pack(side='right', fill='y')
        self.file_list.pack(fill='both', expand=True)
        self.file_list.bind('<Double-Button-1>', lambda event:self.load_selected())
        pane.add(left, width=320)
        right = tk.Frame(pane)
        self.detail = tk.Label(right, text='', anchor='w')
        self.detail.pack(fill='x')
        yscroll = tk.Scrollbar(right)
        self.text = tk.Text(right, wrap='none', undo=True, font=('DejaVu Sans Mono' if os.name != 'nt' else 'Courier New',11), yscrollcommand=yscroll.set)
        yscroll.config(command=self.text.yview)
        yscroll.pack(side='right', fill='y')
        xscroll = tk.Scrollbar(right, orient='horizontal', command=self.text.xview)
        xscroll.pack(side='bottom', fill='x')
        self.text.config(xscrollcommand=xscroll.set)
        self.text.pack(fill='both', expand=True)
        pane.add(right)
        buttons = tk.Frame(self.content)
        buttons.pack(fill='x', pady=8)
        for index, (label, command) in enumerate([('load',self.load_selected),('download',self.download_selected),('edited',self.save_edit),('upload',self.upload_file),('parameters',self.show_parameters)]):
            tk.Button(buttons, text=tr(self.cfg,label), command=command).grid(row=index//3, column=index%3, sticky='ew', padx=3, pady=3)
        for column in range(3):
            buttons.columnconfigure(column, weight=1)
        self.status = tk.StringVar(value=tr(self.cfg,'ready'))
        tk.Label(self.content, textvariable=self.status, anchor='w', wraplength=950).pack(fill='x')

    def run_task(self, task, done, cfg=None):
        if self.busy:
            return
        self.busy = True
        self.status.set(tr(cfg or self.cfg,'working'))
        generation = self.generation
        def worker():
            try:
                self.events.put((generation,done,task(),None))
            except Exception as exc:
                self.events.put((generation,done,None,text_type(exc)))
        thread = threading.Thread(target=worker)
        thread.daemon = True
        thread.start()

    def poll(self):
        try:
            generation, done, data, error = self.events.get_nowait()
            self.busy = False
            if generation == self.generation:
                self.status.set(error or tr(self.cfg,'ready'))
                if error:
                    messagebox.showerror(APP_TITLE,error)
                else:
                    try:
                        done(data)
                    except Exception as exc:
                        messagebox.showerror(APP_TITLE,text_type(exc))
        except queue.Empty:
            pass
        self.after(100,self.poll)

    def confirm_discard(self):
        return self.text.get('1.0','end-1c') == self.original_text or messagebox.askyesno(APP_TITLE,tr(self.cfg,'unsaved'))

    def quit_app(self):
        if self.confirm_discard():
            self.destroy()

    def open_settings(self):
        if not self.busy and self.confirm_discard():
            SettingsDialog(self)

    def apply_config(self, cfg):
        self.generation += 1
        self.cfg = cfg
        self.source = 'all'
        self.programs = []
        self.loaded = None
        self.original_text = ''
        self.content.destroy()
        self.build()
        self.refresh_list()

    def change_language(self, language):
        if self.busy or not self.confirm_discard():
            return
        cfg = dict(self.cfg)
        cfg['language'] = language
        try:
            save_config(cfg)
            self.apply_config(cfg)
        except Exception as exc:
            messagebox.showerror(APP_TITLE,text_type(exc))

    def select_source(self, event=None):
        if self.busy:
            self.source_var.set(tr(self.cfg,self.source))
            return
        self.source = next((kind for kind in ('all','projects','exports','imports') if self.source_var.get() == tr(self.cfg,kind)), 'all')
        self.programs = []
        self.filter_list()
        self.refresh_list()

    def refresh_list(self):
        cfg, source = dict(self.cfg), self.source
        if not cfg.get('api_key'):
            self.open_settings()
            return
        def task():
            api = DesktopAPI(cfg)
            return api.capabilities(), api.list_files(source)
        def done(data):
            info, self.programs = data
            self.account_label.config(text=cfg['server_url']+' | '+info.get('account',cfg.get('account','')))
            self.filter_list()
        self.run_task(task,done)

    def filter_list(self):
        query = self.search.get().lower()
        self.visible_rows = [p for p in self.programs if query in p.get('filename','').lower()]
        self.file_list.delete(0,'end')
        for item in self.visible_rows:
            self.file_list.insert('end', '['+tr(self.cfg,item['source'])+'] '+item['filename']+'   '+item.get('controller','')+'   '+item.get('created_at','')[:16].replace('T',' '))
        self.status.set('%d files' % len(self.visible_rows) if self.visible_rows else tr(self.cfg,'empty'))

    def selected(self):
        if self.busy:
            return None
        indices = self.file_list.curselection()
        if not indices:
            messagebox.showinfo(APP_TITLE,tr(self.cfg,'select'))
            return None
        return dict(self.visible_rows[int(indices[0])])

    def load_selected(self):
        item = self.selected()
        if item is None or not self.confirm_discard():
            return
        if item['source'] == 'projects' and not messagebox.askyesno(APP_TITLE,tr(self.cfg,'review_project')):
            return
        cfg = dict(self.cfg)
        def done(data):
            self.text.delete('1.0','end')
            self.loaded = None if data['binary'] else dict(item,filename=data['filename'])
            if data['binary']:
                text = tr(cfg,'binary')
            else:
                for encoding in ('utf-8-sig','cp1252','latin-1'):
                    try:
                        text = data['content'].decode(encoding)
                        self.encoding = 'utf-8' if encoding == 'utf-8-sig' else encoding
                        break
                    except UnicodeError:
                        continue
            self.text.insert('1.0',text)
            self.original_text = text
            self.detail.config(text=data['filename'])
        self.run_task(lambda:DesktopAPI(cfg).download(item,reviewed=item['source']=='projects'),done)

    def download_selected(self):
        item = self.selected()
        if item is None:
            return
        if item['source'] == 'projects' and not messagebox.askyesno(APP_TITLE,tr(self.cfg,'review_project')):
            return
        cfg = dict(self.cfg)
        def done(data):
            path = filedialog.asksaveasfilename(initialdir=cfg.get('download_dir'),initialfile=data['filename'],filetypes=[('All files','*')])
            if path:
                atomic_download(path,data['content'])
                self.status.set(tr(cfg,'downloaded')+' '+path)
        self.run_task(lambda:DesktopAPI(cfg).download(item,reviewed=item['source']=='projects'),done)

    def save_edit(self):
        if self.busy or not self.loaded:
            return
        if not messagebox.askyesno(APP_TITLE,tr(self.cfg,'new_copy')):
            return
        try:
            content = self.text.get('1.0','end-1c').encode(self.encoding)
        except UnicodeError as exc:
            messagebox.showerror(APP_TITLE,text_type(exc))
            return
        stem, ext = os.path.splitext(self.loaded['filename'])
        cfg = dict(self.cfg)
        self.run_task(lambda:DesktopAPI(cfg).upload(stem+'_edited'+ext,content),lambda result:self.status.set(tr(cfg,'copied')))

    def upload_file(self):
        if self.busy:
            return
        path = filedialog.askopenfilename(filetypes=[('NC files','*.nc *.hnc *.H *.ngc *.tap *.cnc *.gcode'),('All files','*')])
        if not path:
            return
        cfg = dict(self.cfg)
        def task():
            with open(path,'rb') as handle:
                content = handle.read(1048577)
            return DesktopAPI(cfg).upload(os.path.basename(path),content)
        self.run_task(task,lambda result:self.status.set(tr(cfg,'copied')))

    def show_parameters(self):
        item = self.selected()
        if item is None:
            return
        cfg = dict(self.cfg)
        def done(data):
            window = tk.Toplevel(self)
            window.title(tr(cfg,'parameters'))
            window.geometry('720x540')
            text = tk.Text(window,wrap='word',font=('Courier New',11))
            text.pack(fill='both',expand=True)
            # Parameter keys and operation kinds are the API's stable English names.
            # Escape only user-provided Unicode in English mode on machines without Thai fonts.
            text.insert('1.0',json.dumps(data,ensure_ascii=cfg.get('language')=='en',indent=2))
            text.config(state='disabled')
        self.run_task(lambda:DesktopAPI(cfg).parameters(item),done)


if __name__ == '__main__':
    GCodeClientApp().mainloop()
