# -*- coding: utf-8 -*-
"""
server.py — LINE Switcher: เซิร์ฟเวอร์ "แผงปุ่มจากเว็บ" (stdlib ล้วน)
────────────────────────────────────────────────────────────────────────
หน้าที่:
  • เสิร์ฟแผงปุ่ม (panel.html) + API ให้กดสลับบัญชี/เพิ่มบัญชี/ตรวจระบบ
  • PIN กั้นแผง (ตั้งครั้งแรกตอนเปิด) + cookie session
  • เก็บบัญชีแบบเข้ารหัสผ่าน vault.py (DPAPI — ต่อเครื่อง ต่อคน)
  • สั่งงานสลับผ่าน linectl.py (ออกจากระบบ → กรอกอีเมล/รหัส → รอเข้าแอป)

รัน:  python server.py            (เปิดเบราว์เซอร์ให้อัตโนมัติ)
      python server.py --no-browser
Env:  LS_PORT=8893  LS_BIND=127.0.0.1   (LS_BIND=0.0.0.0 = เปิดให้มือถือในบ้าน ในเวอร์ชันถัดไป)
ข้อมูล: %LOCALAPPDATA%\\line-switcher\\  (vault.bin / config.json / state.json / logs/)
"""
import hashlib
import hmac
import json
import os
import pathlib
import re
import secrets
import shutil
import subprocess
import sys
import tempfile
import threading
import time
import webbrowser
from http.server import ThreadingHTTPServer, BaseHTTPRequestHandler
from urllib.parse import urlparse

import linectl
import vault

PORT = int(os.environ.get("LS_PORT", "8893"))
BIND = os.environ.get("LS_BIND", "127.0.0.1")
APP_VER = "0.1.17"
BASE = pathlib.Path(os.environ.get("LOCALAPPDATA", ".")) / "line-switcher"
BASE.mkdir(parents=True, exist_ok=True)
CONF = BASE / "config.json"
STATE = BASE / "state.json"
LINKS = BASE / "shortcuts.json"
PROBE_TXT = BASE / "probe_report.txt"
PANEL = pathlib.Path(__file__).parent / "panel.html"

TOKENS = {}
TOK_LOCK = threading.Lock()
SESSION_HOURS = 8
JOB = {"id": 0, "phase": "idle", "detail": "", "ts": 0.0, "error": "", "cancel": False}
JOB_LOCK = threading.Lock()
SYS = {"checked": False, "line_state": "unknown", "note": "", "ts": 0.0}
SYS_LOCK = threading.Lock()


# ── helpers ──────────────────────────────────────────────────────────────
def load_json(path, default):
    try:
        return json.loads(pathlib.Path(path).read_text(encoding="utf-8"))
    except Exception:
        return default


def save_json(path, obj):
    pathlib.Path(path).write_text(json.dumps(obj, ensure_ascii=False, indent=1), encoding="utf-8")


# ── ลิงก์ลัด (shortcuts) ─────────────────────────────────────────────────
def _norm_url(u):
    """คืน URL ที่ใช้ได้ (http/https เท่านั้น) หรือ '' ถ้าไม่ผ่าน"""
    u = str(u or "").strip()
    if not u or len(u) > 2000:
        return ""
    m = re.match(r"^([a-zA-Z][a-zA-Z0-9+.-]*):", u)
    if m:
        if m.group(1).lower() not in ("http", "https"):
            return ""
        if not re.match(r"^https?://", u, re.I):
            u = re.sub(r"^(https?):", r"\1://", u, count=1, flags=re.I)
    else:
        u = "https://" + u
    p = urlparse(u)
    if p.scheme.lower() not in ("http", "https") or not p.netloc:
        return ""
    if re.search(r"[\s]", p.netloc):
        return ""
    return u


def links_data():
    d = load_json(LINKS, {})
    groups, links = [], []
    for g in (d.get("groups") or []):
        if isinstance(g, dict) and g.get("id") and str(g.get("name") or "").strip():
            groups.append({"id": str(g["id"]), "name": str(g["name"]).strip()[:40]})
    gids = {g["id"] for g in groups}
    for l in (d.get("links") or []):
        if not isinstance(l, dict) or not l.get("id"):
            continue
        url = _norm_url(l.get("url"))
        if not url:
            continue
        grp = str(l.get("group") or "")
        links.append({"id": str(l["id"]),
                      "name": str(l.get("name") or "").strip()[:40] or urlparse(url).netloc,
                      "url": url,
                      "group": grp if grp in gids else ""})
    return {"groups": groups, "links": links}


def save_links(d):
    save_json(LINKS, {"groups": d.get("groups") or [], "links": d.get("links") or []})


CHROME_PATHS = [
    r"C:\Program Files\Google\Chrome\Application\chrome.exe",
    r"C:\Program Files (x86)\Google\Chrome\Application\chrome.exe",
]


def chrome_path():
    for p in CHROME_PATHS:
        if pathlib.Path(p).exists():
            return p
    local = os.environ.get("LOCALAPPDATA") or ""
    if local:
        p = pathlib.Path(local) / "Google" / "Chrome" / "Application" / "chrome.exe"
        if p.exists():
            return str(p)
    try:
        import winreg
        with winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE,
                            r"SOFTWARE\Microsoft\Windows\CurrentVersion\App Paths\chrome.exe") as k:
            v = winreg.QueryValue(k, None)
            if v and pathlib.Path(v).exists():
                return v
    except Exception:
        pass
    return ""


def _sweep_profiles(max_age=86400):
    """ลบโปรไฟล์ชั่วคราวของรอบก่อน ๆ ที่เก่ากว่า 1 วัน"""
    root = pathlib.Path(tempfile.gettempdir())
    now = time.time()
    for d in root.glob("ls-chrome-*"):
        try:
            if now - d.stat().st_mtime > max_age:
                shutil.rmtree(d, ignore_errors=True)
        except Exception:
            pass


def open_links(ids):
    """เปิดแต่ละลิงก์เป็นหน้าต่าง Chrome ไม่ระบุตัวตน — คนละโปรไฟล์ชั่วคราว = session แยกจริง"""
    d = links_data()
    byid = {l["id"]: l for l in d["links"]}
    chrome = chrome_path()
    if not chrome:
        return {"ok": False, "msg": "ไม่พบ Chrome ในเครื่องนี้", "opened": [], "failed": []}
    _sweep_profiles()
    opened, failed = [], []
    flags = getattr(subprocess, "DETACHED_PROCESS", 0) | getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0)
    for lid in ids:
        l = byid.get(str(lid))
        if not l:
            failed.append({"id": str(lid), "msg": "ไม่พบลิงก์"})
            continue
        prof = pathlib.Path(tempfile.gettempdir()) / ("ls-chrome-" + secrets.token_hex(4))
        try:
            prof.mkdir(parents=True, exist_ok=True)
            subprocess.Popen(
                [chrome, "--incognito", "--user-data-dir=" + str(prof),
                 "--no-first-run", "--no-default-browser-check",
                 "--disable-session-crashed-bubble", "--new-window", l["url"]],
                close_fds=True, creationflags=flags,
                stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
            opened.append(l["id"])
        except Exception as e:
            failed.append({"id": l["id"], "msg": str(e)})
        time.sleep(0.15)
    return {"ok": bool(opened), "opened": opened, "failed": failed, "chrome": chrome}


def conf():
    return load_json(CONF, {})


PBKDF2_ITERS = 120_000


def _pbkdf2_sha256_py(pw, salt, iters):
    """PBKDF2-HMAC-SHA256 เวอร์ชันคำนวณเอง (ผลลัพธ์เหมือนของ OpenSSL เป๊ะ)"""
    u = hmac.new(pw, salt + b"\x00\x00\x00\x01", hashlib.sha256).digest()
    out = bytearray(u)
    for _ in range(iters - 1):
        u = hmac.new(pw, u, hashlib.sha256).digest()
        for i in range(len(out)):
            out[i] ^= u[i]
    return bytes(out)


def _pbkdf2_sha256(pw, salt, iters):
    try:
        return hashlib.pbkdf2_hmac("sha256", pw, salt, iters)
    except AttributeError:
        return _pbkdf2_sha256_py(pw, salt, iters)


def _pin_hash(pin, salt_hex):
    return _pbkdf2_sha256(pin.encode("utf-8"), bytes.fromhex(salt_hex), PBKDF2_ITERS).hex()


def _const_eq(a, b):
    try:
        return hmac.compare_digest(a, b)
    except AttributeError:
        if len(a) != len(b):
            return False
        r = 0
        for x, y in zip(a.encode("utf-8"), b.encode("utf-8")):
            r |= x ^ y
        return r == 0


def pin_ok(pin):
    c = conf()
    if not c.get("pin_salt"):
        return False
    return _const_eq(_pin_hash(pin, c["pin_salt"]), c.get("pin_hash", ""))


def new_token():
    t = secrets.token_hex(16)
    with TOK_LOCK:
        now = time.time()
        for k in [k for k, v in TOKENS.items() if v < now]:
            TOKENS.pop(k, None)
        TOKENS[t] = now + SESSION_HOURS * 3600
    _sess_save()
    return t


def token_ok(t):
    if not t:
        return False
    with TOK_LOCK:
        exp = TOKENS.get(t)
        if not exp:
            return False
        if exp < time.time():
            TOKENS.pop(t, None)
            return False
        return True


SESS_FILE = BASE / "sessions.json"


def _sess_load():
    """โหลด session ที่ยังไม่หมดอายุกลับมาตอนเปิดโปรแกรม (แผงไม่ต้องใส่ PIN ซ้ำหลังรีสตาร์ท)"""
    try:
        d = json.loads(SESS_FILE.read_text(encoding="utf-8"))
        now = time.time()
        with TOK_LOCK:
            for k, v in (d or {}).items():
                if isinstance(v, (int, float)) and v > now:
                    TOKENS[k] = v
    except Exception:
        pass


def _sess_save():
    try:
        with TOK_LOCK:
            SESS_FILE.write_text(json.dumps(TOKENS), encoding="utf-8")
    except Exception:
        pass


_sess_load()


def set_job(**kw):
    with JOB_LOCK:
        JOB.update(kw)
        JOB["ts"] = time.time()


def set_job_if(jid, **kw):
    """อัปเดตงานเฉพาะเมื่อยังเป็นงานเดิม (กันเทรดเก่ามาเขียนทับงานใหม่)"""
    with JOB_LOCK:
        if JOB["id"] == jid:
            JOB.update(kw)
            JOB["ts"] = time.time()


def get_job():
    with JOB_LOCK:
        return dict(JOB)


def set_sys(**kw):
    with SYS_LOCK:
        SYS.update(kw)
        SYS["ts"] = time.time()


def get_sys():
    with SYS_LOCK:
        return dict(SYS)


def refresh_sys_from_probe(rep):
    wins = rep.get("windows", [])
    note = time.strftime("ตรวจล่าสุด %d/%m %H:%M")
    if not rep.get("line_pids"):
        st = "app_not_running"
    elif not wins:
        st = "unknown"
        note = "มีแอป LINE อยู่ แต่ยังตรวจหน้าต่างไม่ได้ (รันผ่านปุ่ม bat เพื่อติดตั้งตัวขับ UI)"
    elif any((w.get("class") or "").startswith("Login") for w in wins):
        st = "logged_out"
    else:
        st = "logged_in"
    set_sys(checked=True, line_state=st, note=note)


def run_probe_bg():
    try:
        rep = linectl.probe()
        refresh_sys_from_probe(rep)
    except Exception as e:
        linectl.log("probe bg error: %r" % (e,))


# ── งานสลับ ───────────────────────────────────────────────────────────────
class Cancelled(Exception):
    pass


def _cancel_check(jid):
    with JOB_LOCK:
        if JOB["id"] != jid or JOB["cancel"]:
            raise Cancelled()


def start_switch(acc):
    with JOB_LOCK:
        if JOB["phase"] in ("start", "opening", "logout", "wait_login", "fill", "wait_confirm"):
            return False, "มีงานสลับกำลังรันอยู่ — รอให้จบก่อน"
        JOB["id"] += 1
        JOB.update(phase="start", detail="เริ่ม: เช็คสถานะแอป LINE…", error="", cancel=False, ts=time.time())
        jid = JOB["id"]
    threading.Thread(target=switch_worker, args=(jid, acc), daemon=True).start()
    return True, ""


def switch_worker(jid, acc):
    nick = acc.get("nick", "?")
    try:
        _cancel_check(jid)

        # 0) แอปเปิดอยู่ไหม (หรือเปิดอยู่แต่ซ่อนในถาด)
        st = linectl.status()
        linectl.log("switch[%s] เริ่ม: state=%s | %s" % (jid, st["state"], st.get("detail", "")))
        if st["state"] == "app_not_running":
            set_job_if(jid, phase="opening", detail="สั่งเปิด/ดึงหน้าต่างแอป LINE…")
            r = linectl.open_app()
            if not r.get("ok"):
                raise RuntimeError(r.get("detail", "เปิดแอป LINE ไม่ได้"))
            for _ in range(30):
                time.sleep(1.5)
                _cancel_check(jid)
                if linectl.status()["state"] != "app_not_running":
                    break
            time.sleep(2)
            st = linectl.status()
            linectl.log("switch[%s] หลังสั่งเปิด: state=%s | %s" % (jid, st["state"], st.get("detail", "")))
            if st["state"] == "app_not_running":
                raise RuntimeError("แอป LINE ยังไม่ขึ้นหน้าต่างใน ~45 วิ — คลิกไอคอน LINE ให้หน้าต่างโชว์ก่อน แล้วกดสลับใหม่")

        # 1) ออกจากระบบ (ถ้าล็อกอินอยู่) — ระบบกันเหนียว: กดให้ไม่ได้ → ชวนกดมือ แล้วรอต่อ ไม่ล้มเลิก
        st = linectl.status()
        auto_ok = True
        if st["state"] == "logged_in":
            set_job_if(jid, phase="logout", detail="สั่งแอป LINE ให้ออกจากระบบ…")
            r = linectl.logout()
            linectl.log("switch[%s] logout: ok=%s | steps=%s | err=%s" %
                        (jid, r.get("ok"), " · ".join(r.get("steps", [])), r.get("error", "")))
            auto_ok = bool(r.get("ok"))

        # 2) รอหน้าล็อกอินขึ้น (รอ ~3 นาที — ถ้าอัตโนมัติไม่ได้/ไม่ขึ้นใน ~35 วิ จะชวนกดมือ)
        invite = ("👉 ถ้าหน้าล็อกอินยังไม่ขึ้น: ช่วยกด \u201cออกจากระบบ\u201d เองที่เมนู ⋯ ของ LINE 1 ที "
                  "— ระบบจะรอและพิมพ์ล็อกอินให้ต่อเอง")
        base2 = "รอหน้าล็อกอินขึ้น…" if auto_ok else ("อัตโนมัติกดออกจากระบบไม่ได้ — " + invite)
        set_job_if(jid, phase="wait_login", detail=base2)
        seen_login = False
        invited = not auto_ok
        t0 = time.time()
        i = 0
        while time.time() - t0 < 180:
            _cancel_check(jid)
            if linectl.status()["state"] == "logged_out":
                seen_login = True
                break
            if invited:
                set_job_if(jid, detail=invite + " (รอมา %d วิ)" % int(time.time() - t0))
            elif time.time() - t0 >= 35:
                invited = True
                set_job_if(jid, detail=invite + " (รอมา %d วิ)" % int(time.time() - t0))
            elif i % 10 == 9:
                set_job_if(jid, detail=base2 + " (รอมา %d วิ)" % int(time.time() - t0))
            i += 1
            time.sleep(1.5)
        if not seen_login:
            stx = linectl.status()
            linectl.log("switch[%s] wait_login timeout: state=%s | %s" % (jid, stx["state"], stx.get("detail", "")))
            raise RuntimeError("ยังไม่ขึ้นหน้าล็อกอินใน ~3 นาที (สถานะล่าสุด: %s) — ช่วยกดที่แอป LINE: เมนู ⋯ → \u201cออกจากระบบ\u201d ด้วยมือ แล้วกด \u201cสลับ\u201d ใหม่ (ระบบจะพิมพ์ล็อกอินให้เอง)" % stx["state"])

        # 3) กรอกอีเมล/รหัส (ถ้ามีบันทึกไว้)
        note_manual = ""
        if acc.get("password"):
            set_job_if(jid, phase="fill", detail="กรอกอีเมล + รหัสผ่านให้…")
            r = linectl.fill_login(acc.get("email", ""), acc["password"])
            linectl.log("fill_login(%s): ok=%s | steps=%s | err=%s" %
                        (nick, r.get("ok"), " / ".join(r.get("steps", [])), r.get("error", "")))
            if not r.get("ok"):
                note_manual = " (พิมพ์ให้ไม่สำเร็จ — อาจต้องล็อกอินมือ: %s)" % r.get("error", "")

        # 4) รอเข้าแอป (อัปเดตความเคลื่อนไหวทุก ~15 วิ)
        phone = acc.get("phone") or "มือถือของบัญชีนี้"
        if acc.get("password"):
            base = "ถ้าไลน์ขอแตะยืนยันที่ %s — แตะได้เลย · กำลังรอเข้าแอป…%s" % (phone, note_manual)
        else:
            base = "สแกน QR ด้วย %s · กำลังรอเข้าแอป…" % phone
        set_job_if(jid, phase="wait_confirm", detail=base)
        t0 = time.time()
        i = 0
        while time.time() - t0 < 240:
            _cancel_check(jid)
            stx = linectl.status()
            if stx["state"] == "logged_in":
                stg = load_json(STATE, {})
                stg.update(current_id=acc.get("id", ""), current_nick=nick,
                           when=time.strftime("%d/%m %H:%M"))
                save_json(STATE, stg)
                set_job_if(jid, phase="done", detail="สลับสำเร็จ — ตอนนี้เป็น %s" % nick)
                set_sys(checked=True, line_state="logged_in", note="เพิ่งสลับเป็น %s" % nick)
                return
            if i % 8 == 7:
                set_job_if(jid, detail=base + " · รอมา %d วิ (สถานะแอป: %s)" % (int(time.time() - t0), stx["state"]))
            i += 1
            time.sleep(1.5)
        raise RuntimeError("รอเข้าแอปนานเกิน 4 นาที — ลองกดสลับใหม่ (ถ้าไลน์ค้าง ให้ล็อกอินมือรอบนี้)")

    except Cancelled:
        set_job_if(jid, phase="cancelled", detail="ยกเลิกแล้ว")
    except Exception as e:
        linectl.log("switch error: %r" % (e,))
        set_job_if(jid, phase="error", detail="", error=str(e))


def snapshot(authed):
    c = conf()
    out = {"ok": True, "pin_setup": bool(c.get("pin_salt")), "authed": bool(authed),
           "app_version": APP_VER,
           "accounts": [], "current": {"known": False}, "sys": {"checked": False, "line_state": "unknown"},
           "job": get_job(), "links": [], "groups": []}
    if not authed:
        return out
    accs = vault.load_accounts()
    out["accounts"] = [{"id": a.get("id"), "nick": a.get("nick"), "email": a.get("email", ""),
                        "phone": a.get("phone", ""), "has_pass": bool(a.get("password"))} for a in accs]
    stg = load_json(STATE, {})
    if stg.get("current_id"):
        out["current"] = {"known": True, "id": stg.get("current_id"), "nick": stg.get("current_nick", ""),
                          "when": stg.get("when", "")}
    s = get_sys()
    out["sys"] = {"checked": s["checked"], "line_state": s["line_state"], "note": s["note"], "ts": s["ts"]}
    ld = links_data()
    out["links"] = ld["links"]
    out["groups"] = ld["groups"]
    return out


# ── HTTP ─────────────────────────────────────────────────────────────────
class Handler(BaseHTTPRequestHandler):
    server_version = "LineSwitcher/0.1"

    def log_message(self, fmt, *args):
        pass  # เงียบไว้ (log สำคัญอยู่ใน linectl.log)

    # utils
    def _send(self, code, body, ctype="application/json; charset=utf-8", extra=None):
        if isinstance(body, (dict, list)):
            body = json.dumps(body, ensure_ascii=False)
        if isinstance(body, str):
            body = body.encode("utf-8")
        self.send_response(code)
        self.send_header("Content-Type", ctype)
        self.send_header("Content-Length", str(len(body)))
        self.send_header("Cache-Control", "no-store")
        for k, v in (extra or []):
            self.send_header(k, v)
        self.end_headers()
        self.wfile.write(body)

    def _json_body(self):
        try:
            n = int(self.headers.get("Content-Length") or 0)
            return json.loads(self.rfile.read(n).decode("utf-8")) if n else {}
        except Exception:
            return {}

    def _cookie_token(self):
        raw = self.headers.get("Cookie") or ""
        for part in raw.split(";"):
            k, _, v = part.strip().partition("=")
            if k == "ls_t":
                return v
        return ""

    def _authed(self):
        return token_ok(self._cookie_token())

    # GET
    def do_GET(self):
        path = urlparse(self.path).path
        try:
            if path in ("/", "/panel.html"):
                html = PANEL.read_text(encoding="utf-8")
                return self._send(200, html, "text/html; charset=utf-8")
            if path == "/favicon.ico":
                return self._send(204, b"")
            if path == "/api/state":
                return self._send(200, snapshot(self._authed()))
            if path == "/api/job":
                return self._send(200, {"ok": True, "job": get_job()})
            if path == "/api/logtail":
                try:
                    lines = linectl.LOG.read_text(encoding="utf-8", errors="replace").splitlines()
                except Exception:
                    lines = []
                return self._send(200, {"ok": True, "lines": lines[-80:]})
            return self._send(404, {"ok": False, "msg": "ไม่พบเส้นทาง"})
        except Exception as e:
            linectl.log("API GET %s error: %r" % (path, e))
            return self._send(500, {"ok": False, "msg": repr(e)})

    # POST
    def do_POST(self):
        path = urlparse(self.path).path
        body = self._json_body()
        try:
            # — ไม่ต้องล็อกอิน —
            if path == "/api/pin-setup":
                c = conf()
                if c.get("pin_salt"):
                    return self._send(400, {"ok": False, "msg": "ตั้ง PIN ไปแล้ว"})
                pin = str(body.get("pin") or "").strip()
                if not (4 <= len(pin) <= 8) or not pin.isdigit():
                    return self._send(400, {"ok": False, "msg": "PIN ต้องเป็นตัวเลข 4–8 หลัก"})
                c["pin_salt"] = secrets.token_hex(16)
                c["pin_hash"] = _pin_hash(pin, c["pin_salt"])
                save_json(CONF, c)
                linectl.log("pin: ตั้ง PIN เรียบร้อย (python=%s)" % sys.version.split()[0])
                tok = new_token()
                return self._send(200, {"ok": True, "token": tok},
                                  extra=[("Set-Cookie", "ls_t=%s; HttpOnly; SameSite=Lax; Path=/" % tok)])

            if path == "/api/login":
                pin = str(body.get("pin") or "").strip()
                if pin_ok(pin):
                    tok = new_token()
                    return self._send(200, {"ok": True, "token": tok},
                                      extra=[("Set-Cookie", "ls_t=%s; HttpOnly; SameSite=Lax; Path=/" % tok)])
                time.sleep(0.7)
                return self._send(401, {"ok": False, "msg": "PIN ไม่ถูกต้อง"})

            # — ต้องล็อกอิน —
            if not self._authed():
                return self._send(401, {"ok": False, "msg": "ยังไม่ได้เข้าใช้ (ใส่ PIN ก่อน)"})

            if path == "/api/accounts/add":
                nick = str(body.get("nick") or "").strip()
                if not nick:
                    return self._send(400, {"ok": False, "msg": "ต้องมีชื่อเรียก"})
                accs = vault.load_accounts()
                if len(accs) >= 20:
                    return self._send(400, {"ok": False, "msg": "บัญชีเยอะเกิน 20 แล้ว"})
                acc = {"id": secrets.token_hex(4), "nick": nick,
                       "email": str(body.get("email") or "").strip(),
                       "phone": str(body.get("phone") or "").strip(),
                       "password": "" if body.get("no_pass") else str(body.get("password") or "")}
                accs.append(acc)
                vault.save_accounts(accs)
                return self._send(200, {"ok": True, "id": acc["id"]})

            if path == "/api/accounts/remove":
                aid = str(body.get("id") or "")
                accs = [a for a in vault.load_accounts() if a.get("id") != aid]
                vault.save_accounts(accs)
                stg = load_json(STATE, {})
                if stg.get("current_id") == aid:
                    save_json(STATE, {})
                return self._send(200, {"ok": True})

            if path == "/api/switch":
                aid = str(body.get("id") or "")
                acc = next((a for a in vault.load_accounts() if a.get("id") == aid), None)
                if not acc:
                    return self._send(404, {"ok": False, "msg": "ไม่พบบัญชีนี้"})
                ok, msg = start_switch(acc)
                return self._send(200 if ok else 409, {"ok": ok, "msg": msg})

            if path == "/api/cancel":
                with JOB_LOCK:
                    JOB["cancel"] = True
                return self._send(200, {"ok": True})

            if path == "/api/probe":
                rep = linectl.probe()
                refresh_sys_from_probe(rep)
                try:
                    txt = PROBE_TXT.read_text(encoding="utf-8")
                except Exception:
                    txt = ""
                return self._send(200, {"ok": True, "summary": get_sys(), "report": txt[:1800]})

            if path == "/api/links/save":
                d = links_data()
                lid = str(body.get("id") or "").strip()
                url = _norm_url(body.get("url"))
                if not url:
                    return self._send(400, {"ok": False, "msg": "ลิงก์ต้องขึ้นต้น http:// หรือ https://"})
                name = str(body.get("name") or "").strip()[:40] or urlparse(url).netloc
                grp = str(body.get("group") or "")
                if grp not in {g["id"] for g in d["groups"]}:
                    grp = ""
                cur = next((l for l in d["links"] if l["id"] == lid), None)
                if cur:
                    cur.update({"name": name, "url": url, "group": grp})
                else:
                    if len(d["links"]) >= 60:
                        return self._send(400, {"ok": False, "msg": "ลิงก์เยอะเกิน 60 แล้ว"})
                    lid = secrets.token_hex(4)
                    d["links"].append({"id": lid, "name": name, "url": url, "group": grp})
                save_links(d)
                return self._send(200, {"ok": True, "id": lid})

            if path == "/api/links/remove":
                d = links_data()
                lid = str(body.get("id") or "")
                d["links"] = [l for l in d["links"] if l["id"] != lid]
                save_links(d)
                return self._send(200, {"ok": True})

            if path == "/api/groups/save":
                d = links_data()
                gid = str(body.get("id") or "").strip()
                name = str(body.get("name") or "").strip()[:40]
                if not name:
                    return self._send(400, {"ok": False, "msg": "ต้องมีชื่อกลุ่ม"})
                cur = next((g for g in d["groups"] if g["id"] == gid), None)
                if cur:
                    cur["name"] = name
                else:
                    if len(d["groups"]) >= 20:
                        return self._send(400, {"ok": False, "msg": "กลุ่มเยอะเกิน 20 แล้ว"})
                    gid = secrets.token_hex(3)
                    d["groups"].append({"id": gid, "name": name})
                save_links(d)
                return self._send(200, {"ok": True, "id": gid})

            if path == "/api/groups/remove":
                d = links_data()
                gid = str(body.get("id") or "")
                d["groups"] = [g for g in d["groups"] if g["id"] != gid]
                for l in d["links"]:
                    if l["group"] == gid:
                        l["group"] = ""
                save_links(d)
                return self._send(200, {"ok": True})

            if path == "/api/install-shortcut":
                r = install_shortcuts()
                linectl.log("shortcut: %s" % ("ok" if r.get("ok") else r.get("msg", "")))
                return self._send(200 if r.get("ok") else 500, r)

            if path == "/api/quit":
                def _bye():
                    time.sleep(0.4)
                    os._exit(0)
                threading.Thread(target=_bye, daemon=True).start()
                return self._send(200, {"ok": True})

            if path == "/api/links/open":
                ids = body.get("ids")
                if isinstance(ids, str):
                    ids = [ids]
                if not isinstance(ids, list) or not ids:
                    return self._send(400, {"ok": False, "msg": "ไม่ได้ระบุลิงก์"})
                ids = [str(x) for x in ids][:30]
                r = open_links(ids)
                linectl.log("links: เปิด %d/%d ลิงก์ (chrome=%s)" % (len(r.get("opened") or []), len(ids), r.get("chrome") or "-"))
                return self._send(200 if r.get("ok") else 500, r)

            if path == "/api/line-open":
                return self._send(200, linectl.open_app())

            return self._send(404, {"ok": False, "msg": "ไม่พบเส้นทาง"})
        except Exception as e:
            linectl.log("API POST %s error: %r" % (path, e))
            return self._send(500, {"ok": False, "msg": repr(e)})


def install_shortcuts():
    """สร้างทางลัดเดสก์ท็อป + Startup (ไม่ต้องใช้ .bat)"""
    here = pathlib.Path(__file__).resolve().parent
    uv = shutil.which("uv") or str(pathlib.Path(os.environ.get("USERPROFILE", "")) / ".local" / "bin" / "uv.exe")
    icon = here / "line-switcher.ico"
    home = pathlib.Path(os.environ.get("USERPROFILE", str(pathlib.Path.home())))
    targets = [home / "Desktop" / "LINE Switcher.lnk",
               pathlib.Path(os.environ.get("APPDATA", "")) / "Microsoft" / "Windows" / "Start Menu" / "Programs" / "Startup" / "LINE Switcher (เปิดอัตโนมัติ).lnk"]
    ps = ["$w = New-Object -ComObject WScript.Shell"]
    for t in targets:
        t.parent.mkdir(parents=True, exist_ok=True)
        ps.append('$s = $w.CreateShortcut("%s")' % str(t).replace('"', '""'))
        ps.append('$s.TargetPath = "%s"' % uv.replace('"', '""'))
        ps.append('$s.Arguments = "run --no-project --with pywinauto python server.py --no-browser"')
        ps.append('$s.WorkingDirectory = "%s"' % str(here).replace('"', '""'))
        if icon.exists():
            ps.append('$s.IconLocation = "%s"' % str(icon).replace('"', '""'))
        ps.append('$s.WindowStyle = 7')
        ps.append('$s.Description = "LINE Switcher"')
        ps.append('$s.Save()')
    script = here / "_make_shortcut.ps1"
    script.write_text((chr(13) + chr(10)).join(ps), encoding="utf-8-sig")
    try:
        r = subprocess.run(["powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", str(script)],
                           capture_output=True, text=True, timeout=90)
        ok = r.returncode == 0
        msg = (r.stderr or r.stdout or "").strip()[:400]
    finally:
        try:
            script.unlink()
        except Exception:
            pass
    return {"ok": ok, "paths": [str(t) for t in targets], "msg": msg}


def main():
    argv = sys.argv[1:]
    no_browser = "--no-browser" in argv
    if "--install-shortcut" in argv:
        res = install_shortcuts()
        print(("สร้างทางลัดแล้ว:\n- " + "\n- ".join(res["paths"])) if res["ok"] else ("สร้างทางลัดไม่สำเร็จ: " + res.get("msg", "")))
        return
    bind, port = BIND, PORT
    for i, a in enumerate(argv):
        if a.startswith("--bind="):
            bind = a.split("=", 1)[1]
        elif a == "--bind" and i + 1 < len(argv):
            bind = argv[i + 1]
        elif a.startswith("--port="):
            port = int(a.split("=", 1)[1])
        elif a == "--port" and i + 1 < len(argv):
            port = int(argv[i + 1])
    srv = ThreadingHTTPServer((bind, port), Handler)
    url = "http://127.0.0.1:%d/" % port
    print("LINE Switcher — แผงปุ่มอยู่ที่ %s" % url)
    print("ข้อมูล: %s" % BASE)
    print("(ปิดหน้าต่างนี้ = ปิดโปรแกรม)")
    try:
        linectl.log("server boot: python=%s | pbkdf2_hmac=%s | %s:%d" % (
            sys.version.split()[0], hasattr(hashlib, "pbkdf2_hmac"), bind, port))
    except Exception:
        pass
    threading.Thread(target=run_probe_bg, daemon=True).start()
    if not no_browser:
        threading.Timer(1.2, lambda: webbrowser.open(url)).start()
    try:
        srv.serve_forever()
    except KeyboardInterrupt:
        pass


if __name__ == "__main__":
    main()
