# launcher.pyw - GenericAgent 服务启动器
# 纯 tkinter + 标准库，零第三方依赖，跨平台
import os, sys, socket, subprocess, threading
import tkinter as tk
from tkinter import ttk
from collections import deque

LOCK_PORT = 19735
BASE_DIR = os.path.dirname(os.path.abspath(__file__))


def acquire_singleton():
    s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    try:
        s.bind(('127.0.0.1', LOCK_PORT)); s.listen(1);return s
    except OSError: return None

def discover_services():
    services = []
    EXCLUDES = {'goal_mode.py', 'chatapp_common.py', 'tuiapp.py'}
    reflect_dir = os.path.join(BASE_DIR, 'reflect')
    if os.path.isdir(reflect_dir):
        for f in sorted(os.listdir(reflect_dir)):
            if f.endswith('.py') and not f.startswith('_') and f not in EXCLUDES:
                services.append({
                    'name': 'reflect/' + f,
                    'cmd': [sys.executable, 'agentmain.py', '--reflect', 'reflect/' + f],
                })
    frontends_dir = os.path.join(BASE_DIR, 'frontends')
    if os.path.isdir(frontends_dir):
        for f in sorted(os.listdir(frontends_dir)):
            if 'app' in f and f.endswith('.py') and f not in EXCLUDES:
                if 'stapp' in f: cmd = [sys.executable, '-m', 'streamlit', 'run', 'frontends/' + f, '--server.headless=true']
                else: cmd = [sys.executable, 'frontends/' + f]
                services.append({'name': 'frontends/' + f, 'cmd': cmd})
    return services


class ServiceManager:
    def __init__(self):
        self.procs = {}
        self.buffers = {}

    def start(self, name, cmd):
        if name in self.procs and self.procs[name].poll() is None:
            return
        self.buffers[name] = deque(maxlen=500)
        env = os.environ.copy()
        env['PYTHONUNBUFFERED'] = '1'
        kw = dict(cwd=BASE_DIR, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
                  text=True, bufsize=1, env=env)
        if sys.platform == 'win32':
            kw['creationflags'] = subprocess.CREATE_NO_WINDOW
        proc = subprocess.Popen(cmd, **kw)
        self.procs[name] = proc
        threading.Thread(target=self._reader, args=(name, proc), daemon=True).start()

    def _reader(self, name, proc):
        try:
            for line in proc.stdout:
                self.buffers[name].append(line)
        except Exception:
            pass

    def stop(self, name):
        proc = self.procs.get(name)
        if proc and proc.poll() is None:
            proc.terminate()
            try:
                proc.wait(timeout=5)
            except subprocess.TimeoutExpired:
                proc.kill()

    def is_running(self, name):
        proc = self.procs.get(name)
        return proc is not None and proc.poll() is None

    def stop_all(self):
        for name in list(self.procs):
            self.stop(name)

    def get_output(self, name):
        buf = self.buffers.get(name)
        return list(buf) if buf else []


class LauncherApp:
    def __init__(self, root):
        self.root = root
        self.root.title('GenericAgent Launcher')
        self.root.geometry('720x740')
        self.root.protocol('WM_DELETE_WINDOW', self.on_close)

        self.mgr = ServiceManager()
        self.services = discover_services()
        self.check_vars = {}
        self.selected = None

        self._build_ui()
        self._poll()

    def _build_ui(self):
        # 标题行：左边标签，右边 Rescan 按钮
        header = ttk.Frame(self.root)
        header.pack(fill='x', padx=8, pady=(8, 0))
        ttk.Label(header, text='Services', font=('', 10, 'bold')).pack(side='left')
        ttk.Button(header, text='\u27f3 Rescan', width=10,
                   command=self._rescan).pack(side='right')

        svc_frame = ttk.LabelFrame(self.root, padding=5)
        svc_frame.pack(fill='x', padx=8, pady=(2, 4))

        self.svc_container = ttk.Frame(svc_frame)
        self.svc_container.pack(fill='x')

        self.status_labels = {}
        self.row_frames = {}
        self.name_labels = {}
        self._build_service_rows()

        self.output_frame = ttk.LabelFrame(self.root, text='Output', padding=5)
        self.output_frame.pack(fill='both', expand=True, padx=8, pady=(4, 8))

        self.output_text = tk.Text(
            self.output_frame, wrap='word', state='disabled',
            bg='#1e1e1e', fg='#d4d4d4',
            font=('Consolas', 9), insertbackground='white')
        sb = ttk.Scrollbar(self.output_frame, command=self.output_text.yview)
        self.output_text.configure(yscrollcommand=sb.set)
        sb.pack(side='right', fill='y')
        self.output_text.pack(fill='both', expand=True)

    def _build_service_rows(self):
        for svc in self.services:
            name = svc['name']
            row = tk.Frame(self.svc_container, cursor='hand2', padx=4, pady=2)
            row.pack(fill='x', pady=1)
            self.row_frames[name] = row

            running = self.mgr.is_running(name)
            var = self.check_vars.get(name, tk.BooleanVar(value=running))
            if running:
                var.set(True)
            self.check_vars[name] = var
            cb = ttk.Checkbutton(
                row, variable=var,
                command=lambda n=name, v=var, s=svc: self._toggle(n, v, s))
            cb.pack(side='left')

            name_lbl = tk.Label(row, text=name, anchor='w', cursor='hand2',
                                bg=row.cget('bg'))
            name_lbl.pack(side='left', fill='x', expand=True)
            self.name_labels[name] = name_lbl

            st = 'running' if running else 'stopped'
            fg = 'green' if running else 'gray'
            lbl = ttk.Label(row, text=st, foreground=fg, width=10)
            lbl.pack(side='right')
            self.status_labels[name] = lbl

            name_lbl.bind('<Button-1>', lambda e, n=name: self._select(n))
            row.bind('<Button-1>', lambda e, n=name: self._select(n))

    def _rescan(self):
        # 记住正在运行的服务
        running_names = {n for n in self.mgr.procs if self.mgr.is_running(n)}
        # 清除旧行
        for w in self.svc_container.winfo_children():
            w.destroy()
        self.status_labels.clear()
        self.row_frames.clear()
        self.name_labels.clear()
        # 清除不再运行的 check_vars
        old_vars = {k: v for k, v in self.check_vars.items() if k in running_names}
        self.check_vars.clear()
        self.check_vars.update(old_vars)
        # 重新扫描
        self.services = discover_services()
        self._build_service_rows()
        # 如果选中的服务不在新列表中，清除选中
        svc_names = {s['name'] for s in self.services}
        if self.selected and self.selected not in svc_names:
            self.selected = None
            self.output_frame.configure(text='Output')

    def _toggle(self, name, var, svc):
        if var.get():
            self.mgr.start(name, svc['cmd'])
            self._select(name)
        else:
            self.mgr.stop(name)

    def _select(self, name):
        self.selected = name
        # 高亮选中行
        for n, row in self.row_frames.items():
            if n == name:
                row.configure(bg='#cce5ff')
                self.name_labels[n].configure(bg='#cce5ff')
            else:
                row.configure(bg='SystemButtonFace')
                self.name_labels[n].configure(bg='SystemButtonFace')
        self.output_frame.configure(text=f'Output - {name}')
        self.root.after(50, self._refresh_output)

    def _refresh_output(self):
        if not self.selected:
            return
        lines = self.mgr.get_output(self.selected)
        new_text = ''.join(lines[-200:])

        # 跳过无变化的刷新，避免不必要的闪烁和位置扰动
        current = self.output_text.get('1.0', 'end-1c')
        if new_text.rstrip('\n') == current.rstrip('\n'):
            return

        # 记录滚动状态
        _top, bot = self.output_text.yview()
        at_bottom = bot >= 0.99

        # 记录「距底部的行偏移」用于非底部时精确恢复位置
        if not at_bottom:
            old_total = int(self.output_text.index('end-1c').split('.')[0])
            first_vis = int(float(self.output_text.index('@0,0')))
            offset_from_end = old_total - first_vis

        self.output_text.configure(state='normal')
        self.output_text.delete('1.0', 'end')
        self.output_text.insert('end', new_text)
        self.output_text.configure(state='disabled')

        if at_bottom:
            self.output_text.see('end')
        else:
            # 用距底部偏移恢复，不受总行数变化影响
            new_total = int(self.output_text.index('end-1c').split('.')[0])
            target = max(1, new_total - offset_from_end)
            self.output_text.yview_moveto(0)  # 先归零避免残留
            self.output_text.see(f'{target}.0')

    def _poll(self):
        for svc in self.services:
            name = svc['name']
            running = self.mgr.is_running(name)
            lbl = self.status_labels[name]
            if running:
                lbl.configure(text='running', foreground='green')
            else:
                lbl.configure(text='stopped', foreground='gray')
                if self.check_vars[name].get():
                    self.check_vars[name].set(False)
        self._refresh_output()
        self.root.after(1000, self._poll)

    def on_close(self):
        self.mgr.stop_all()
        self.root.destroy()


if __name__ == '__main__':
    lock = acquire_singleton()
    if lock is None:
        try:
            import tkinter.messagebox as mb
            r = tk.Tk()
            r.withdraw()
            mb.showinfo('Launcher', 'Already running.')
            r.destroy()
        except Exception:
            pass
        sys.exit(0)

    root = tk.Tk()
    app = LauncherApp(root)
    root.mainloop()
    lock.close()
