#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
================================================================================
  Excel To Chatbot  v0.1b
================================================================================
"""

import os, sys, json, re, fnmatch, webbrowser, threading
from datetime import datetime
import tkinter as tk
from tkinter import ttk, filedialog, messagebox, scrolledtext
import pandas as pd

APP_TITLE = "Excel To Chatbot"
VERSION   = "0.1b"
OUT_DIR   = "gen_output"

# Embedded Base64 Icon directly in the generated script
ICON_B64 = "iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAABHNCSVQICAgIfAhkiAAAAAlwSFlzAAALEgAACxIB0t1+/AAAABZ0RVh0Q3JlYXRpb24gVGltZQAxMS8xNC8yMQA1oJ7AAAAAEnRFWHRTb2Z0d2FyZQBnbnBhaW50IDIuNC4zmy1XJgAAALJJREFUWEftlzEOgzAMRV/HSBkmYARmYARGYARGyAhnABJESCQVKlRILM+S37t3/rPkT7aV6yZJZmZmbgD0wA3o6l4P6IHbN0zAZWa+r9i1VFWN4zhG3/dRVZXTzHxvAswZgqqqEGMshBCw1lqccx8hZ34yAdy11k7OuU8z8wI0TQPe+yA17T03AdQzADnnl0SmlnPO74lMLf9rAi7n3AewH6FmXoDuT4CpmYDXU90EvJ6al0w+zcwP+kHqX1v24kAAAAAASUVORK5CYII="

def base_dir():
    if getattr(sys, "frozen", False): return os.path.dirname(sys.executable)
    return os.path.dirname(os.path.abspath(__file__))

def find_files_insensitive(base_path, pattern):
    pattern = pattern.lower()
    matched = []
    try:
        for f in os.listdir(base_path):
            if fnmatch.fnmatch(f.lower(), pattern):
                matched.append(os.path.join(base_path, f))
    except Exception:
        pass
    return matched

def load_dsl_data(dsl_text, log_fn):
    db = {}
    patterns = re.findall(r'@([^!]+)!([^!]+)!', dsl_text)
    if not patterns:
        log_fn("ไม่พบรูปแบบ @ไฟล์!ชีต! ใน Pattern")
        return db

    base = base_dir()
    for f_pat, s_pat in set(patterns):
        f_pat, s_pat = f_pat.strip(), s_pat.strip()
        log_fn(f"กำลังค้นหาข้อมูล: {f_pat} -> Sheet: {s_pat}")

        # Robust case-insensitive search
        matched_files = find_files_insensitive(base, f_pat)
        if not matched_files:
            log_fn(f"  > ไม่พบไฟล์ที่ตรงกับ '{f_pat}'")
            continue

        for fp in matched_files:
            fname = os.path.basename(fp)
            if fname not in db: db[fname] = {}
            try:
                xl = pd.ExcelFile(fp)
                for sh in xl.sheet_names:
                    if fnmatch.fnmatch(sh.lower(), s_pat.lower()):
                        if sh not in db[fname]:
                            # keep_default_na=False avoids turning empty strings into "nan"
                            df = xl.parse(sh, dtype=str, keep_default_na=False)
                            cols = [str(c).strip() for c in df.columns]
                            rows = df.values.tolist()
                            db[fname][sh] = {"cols": cols, "rows": rows}
                            log_fn(f"  > โหลดสำเร็จ: {fname} [{sh}] ({len(rows):,} แถว)")
            except Exception as e:
                log_fn(f"  > แจ้งเตือน: อ่านไฟล์ {fname} ไม่สำเร็จ ({e})")
    return db

def build_html(db_dict, dsl_text, gen_time):
    meta = {"gen_time": gen_time}
    html = HTML_TEMPLATE
    html = html.replace("%%META%%", json.dumps(meta, ensure_ascii=False, separators=(',', ':')))
    html = html.replace("%%DB%%", json.dumps(db_dict, ensure_ascii=False, separators=(',', ':')).replace("</", "<\\/"))
    html = html.replace("%%DSL%%", json.dumps(dsl_text, ensure_ascii=False).replace("</", "<\\/"))
    return html

DEFAULT_PATTERN = """\
---
@BrandA_Price*.xlsx!Shee*![${Item No.} ({Item Description}) ราคาขาย {ราคาขาย} บาท/ตัว L/T . weeks]
---
#0=@BrandB_Price*.xlsx!Selling*!${Item number} {Remark (สินค้าเลิกผลิต)}==เลิกผลิต*;OUT${Item number} {Type description} เลิกผลิตBREAK
#1=@BrandB_Price*.xlsx!Selling*!${Item number} {สินค้า ABC}==ABC*;OUT${Item number} {Type description} เป็นสินค้ากลุ่ม ABC รบกวนติดต่อฝ่ายขายBREAK
#2=@BrandB_Price*.xlsx!Selling*!${Item number} {MOQ}>1;สั่งซื้อยกกล่อง {MOQ} ตัว
#3=@Warehouse_Stock*.xlsx!stock*!${Item} {Lead Time}==21-24;5-6||{Lead Time}==25-31;6-7||{Lead Time}==32-37;7-8||{Lead Time}==38-45;8-9||{Lead Time}==46-52;9-10||{Lead Time}==53-60;10-11||{Lead Time}==61-63;11-12||{Lead Time}==64-69;12-13||{Lead Time}==70-77;13-14||{Lead Time}==78-85;14-15||{Lead Time}==86-90;15-16||{Lead Time}==91-97;16-17||{Lead Time}==98-104;17-18||{Lead Time}>104;20++
#4=@BrandB_Price*.xlsx!Selling*!${Item number} {Remark (สินค้าเลิกผลิต)}!=เลิกผลิต* && {Remark (สินค้าเลิกผลิต)}NONULL;***{Remark (สินค้าเลิกผลิต)}
#5=@Warehouse_Stock*.xlsx!stock*!${Item} {Free Stock*}>1;***Th Stock {Free Stock*} ไม่ยืนสต๊อก
#6=@Warehouse_Stock*.xlsx!stock*!${Item} {Signal Code}NONULL;***{Signal Co*} {Descript*}
#7=@BrandB_Price*.xlsx!Selling*!${Item number} {Remark (สินค้ามาเรือ)}NONULL;{Remark (สินค้ามาเรือ)}
@BrandB_Price*.xlsx!Selling*!${Item number} {Type description} ราคาขาย {ราคาขาย} บาท/ตัว {#2} ( L/T {#7}:{{#3} Weeks} ) {#4} {#5} {#6}
---

"""

DSL_HELP = """\
===================================================================
  Excel To Chatbot - DSL Reference (คู่มือการใช้งาน Pattern)
===================================================================

สัญลักษณ์หลัก
@     = เลือกไฟล์ Excel (เช่น @PxC_*.xlsx)
!     = คั่นระหว่างไฟล์ / Sheet / โครงสร้าง
*     = Wildcard ค้นหาไฟล์อัตโนมัติ (เช่น Sheet*)
[]    = คลุมกลุ่มข้อความสำหรับไฟล์เดียว
${}   = คอลัมน์ที่เป็น Key หลักในการค้นหา (เช่น ${Item number})
{}    = คอลัมน์ที่ต้องการดึงข้อมูลมาแสดง (เช่น {Type description})
---   = เส้นคั่น ใช้เริ่มชุดคำสั่งใหม่ หรือแยกไฟล์

การตั้งเงื่อนไข (Rules)
#0=, #1=  = กำหนดเงื่อนไขเรียงตามลำดับความสำคัญ (#0 สำคัญสุด)
;         = จบเงื่อนไข ด้านหลัง ; คือข้อความที่จะแสดงถ้าตรงเงื่อนไข
||        = เงื่อนไข OR (หรือ)
&&        = เงื่อนไข AND (และ)

ตัวดำเนินการ (Operators)
==    เท่ากับ                 !=    ไม่เท่ากับ
>     มากกว่า                  <     น้อยกว่า
>=    มากกว่าหรือเท่ากับ          <=    น้อยกว่าหรือเท่ากับ
NONULL  มีข้อมูล (ไม่เป็นค่าว่าง)
OUT     ส่งผลลัพธ์ทันที
BREAK   หยุดการทำงานของ Rules อื่นเมื่อเงื่อนไขนี้เป็นจริง

การขึ้นบรรทัดใหม่ใน Template (Newline)
\\n  = ขึ้นบรรทัดใหม่ในผลลัพธ์ Chat
      เช่น ราคา {ราคาขาย} \\nบาท/ตัว
\\n\\n = เว้น 1 บรรทัดว่าง (สองครั้งติดกัน)

END; = ข้อความท้ายสุด (แสดงครั้งเดียวหลังผลลัพธ์ทั้งหมด)
  วางต่อท้ายบรรทัด @ เช่น
  @File*.xlsx!Sheet*![${Key} {Col}] END;\\nหมายเหตุ
  - ถ้าค้นหาหลาย Code พร้อมกัน END; แสดงเพียงครั้งเดียวตอนท้าย
  - END; รองรับ \\n ด้วย เช่น END;\\n\\nราคานี้ไม่รวม VAT

การแนบข้อความและลำดับความสำคัญสำรอง (Fallback Priority)
{#7}:{{#3} Weeks}:{#6}
- หมายถึง ถ้า #7 มีข้อมูลให้ใช้ของ #7 อย่างเดียวไม่ต้องมีคำว่า Weeks ต่อท้าย
- ถ้า #7 ไม่มี แต่ #3 มีข้อมูล ให้แสดงผลของ #3 แล้วต่อด้วยคำว่า Weeks
- ถ้าไม่มีทั้ง #7 และ #3 ให้ใช้ #6 ไปเลย
"""

BG   = "#06090f"; P1 = "#0a0f1e"; P2 = "#0c1525"
BD   = "#1a2540"; TX = "#dde3f0"; MU = "#8892a8"; AC = "#f50505"
MONO = ("Consolas", 11)

class App:
    def __init__(self, root):
        self.root = root
        root.title(f"{APP_TITLE} v{VERSION}")
        root.geometry("980x760")
        root.minsize(760, 520)
        root.configure(bg=BG)

        # Maximize Window
        try:
            root.state('zoomed')
        except Exception:
            try: root.attributes('-zoomed', True)
            except: pass

        # Set App Icon correctly directly from embedded string
        try:
            self.icon_img = tk.PhotoImage(data=ICON_B64)
            root.iconphoto(False, self.icon_img)
        except Exception: 
            pass

        self.cur_file = ""
        self._build()
        self._set_text(DEFAULT_PATTERN)
        self._log(f"{APP_TITLE} {VERSION} ready")
        self._upd_meta()

    def _build(self):
        self._menu()
        self._toolbar()

        paned = tk.PanedWindow(self.root, orient=tk.VERTICAL, bg=BD, sashwidth=4)
        paned.pack(fill=tk.BOTH, expand=True, padx=8, pady=(4,0))

        top_frm = tk.Frame(paned, bg=BG)
        paned.add(top_frm, stretch="always", minsize=200)

        sy = tk.Scrollbar(top_frm, command=self._on_scroll_y)
        self.editor = tk.Text(top_frm, font=MONO, bg=P1, fg=TX, insertbackground=AC, selectbackground="#1e3a5f", bd=0, undo=True, wrap=tk.NONE)
        sx = tk.Scrollbar(top_frm, command=self.editor.xview, orient=tk.HORIZONTAL)

        sx.pack(side=tk.BOTTOM, fill=tk.X)
        sy.pack(side=tk.RIGHT, fill=tk.Y)

        self.lineno = tk.Text(top_frm, width=4, bg=P1, fg=MU, bd=0, font=MONO, state=tk.DISABLED, selectbackground=P1, selectforeground=MU)
        self.lineno.pack(side=tk.LEFT, fill=tk.Y, pady=2)

        self.editor.pack(side=tk.LEFT, fill=tk.BOTH, expand=True, pady=2)

        self.editor.config(yscrollcommand=lambda *a: self._sync_y(*a, sb=sy), xscrollcommand=sx.set)
        self.lineno.config(yscrollcommand=lambda *a: self._sync_y(*a, sb=sy))

        self.editor.bind("<Control-c>", self._copy_text)
        self.editor.bind("<Control-v>", self._paste_text)
        self.editor.bind("<Control-x>", self._cut_text)
        self.editor.bind("<Control-a>", self._select_all_text)

        self._make_context_menu()
        self.editor.bind("<KeyRelease>", self._update_lineno)
        self.editor.bind("<MouseWheel>", self._update_lineno)
        self.editor.bind("<<Paste>>", lambda e: self.root.after(10, self._update_lineno))
        self.editor.bind("<Return>", lambda e: self.root.after(10, self._update_lineno))
        self.editor.bind("<BackSpace>", lambda e: self.root.after(10, self._update_lineno))

        bot_frm = tk.Frame(paned, bg=P2)
        paned.add(bot_frm, stretch="never", minsize=160)

        lb = tk.Frame(bot_frm, bg=P2, padx=8, pady=4)
        lb.pack(fill=tk.X)
        tk.Label(lb, text="System Log", bg=P2, fg=MU, font=("Segoe UI", 9, "bold")).pack(side=tk.LEFT)
        tk.Button(lb, text="Clear Log", bg=P2, fg=TX, bd=0, cursor="hand2", command=self._clear_log).pack(side=tk.RIGHT)

        self.logbox = scrolledtext.ScrolledText(bot_frm, font=("Consolas", 10), bg=P2, fg=TX, bd=0, highlightthickness=0, state=tk.DISABLED, height=8, wrap=tk.WORD, padx=6, pady=4)
        self.logbox.pack(fill=tk.BOTH, expand=True, pady=(0, 6))

        self._statusbar()

    def _copy_text(self, event):
        self.editor.event_generate("<<Copy>>")
        return "break"

    def _paste_text(self, event):
        self.editor.event_generate("<<Paste>>")
        self.root.after(10, self._update_lineno)
        return "break"

    def _cut_text(self, event):
        self.editor.event_generate("<<Cut>>")
        self.root.after(10, self._update_lineno)
        return "break"

    def _select_all_text(self, event):
        self.editor.tag_add("sel", "1.0", "end")
        return "break"

    def _sync_y(self, *args, sb=None):
        sb.set(*args)
        self.lineno.yview_moveto(args[0])

    def _on_scroll_y(self, *args):
        self.editor.yview(*args)
        self.lineno.yview(*args)

    def _update_lineno(self, event=None):
        lines = int(self.editor.index("end-1c").split(".")[0])
        line_str = "\n".join(str(i) for i in range(1, lines + 1))
        self.lineno.config(state=tk.NORMAL)
        self.lineno.delete("1.0", tk.END)
        self.lineno.insert("1.0", line_str)
        self.lineno.config(state=tk.DISABLED)

    def _make_context_menu(self):
        m = tk.Menu(self.root, tearoff=0)
        m.add_command(label="Copy (Ctrl+C)", command=lambda: self.editor.event_generate("<<Copy>>"))
        m.add_command(label="Cut (Ctrl+X)", command=lambda: self.editor.event_generate("<<Cut>>"))
        m.add_command(label="Paste (Ctrl+V)", command=lambda: self.editor.event_generate("<<Paste>>"))
        m.add_separator()
        m.add_command(label="Select All (Ctrl+A)", command=lambda: self.editor.tag_add("sel", "1.0", "end"))
        def show(e):
            m.tk_popup(e.x_root, e.y_root)
        self.editor.bind("<Button-3>", show)

    def _menu(self):
        m = tk.Menu(self.root)
        f = tk.Menu(m, tearoff=0)
        f.add_command(label="New",        command=self._new)
        f.add_command(label="Open...",    command=self._open)
        f.add_command(label="Save",       command=self._save,   accelerator="Ctrl+S")
        f.add_command(label="Save As...", command=self._save_as)
        f.add_separator()
        f.add_command(label="Exit",       command=self.root.quit)
        r = tk.Menu(m, tearoff=0)
        r.add_command(label="Check Pattern",  command=self._check, accelerator="F4")
        r.add_command(label="Gen Chat HTML",  command=self._gen,   accelerator="F5")
        r.add_separator()
        r.add_command(label="Open Output Folder", command=self._open_outdir)
        h = tk.Menu(m, tearoff=0)
        h.add_command(label="DSL Help / Reference", command=self._help)
        h.add_separator()
        h.add_command(label="Build .exe", command=self._build_cmd)
        h.add_separator()
        h.add_command(label="About", command=self._about)
        m.add_cascade(label="File", menu=f)
        m.add_cascade(label="Run",  menu=r)
        m.add_cascade(label="Help", menu=h)
        self.root.config(menu=m)
        self.root.bind("<Control-s>", lambda e: self._save())
        self.root.bind("<F4>",        lambda e: self._check())
        self.root.bind("<F5>",        lambda e: self._gen())

    def _toolbar(self):
        bar = tk.Frame(self.root, bg=P1, highlightthickness=1, highlightbackground=BD, padx=10, pady=8)
        bar.pack(fill=tk.X)
        def btn(txt, cmd, primary=False):
            bg = AC if primary else P1
            fg = "#060915" if primary else TX
            b = tk.Button(bar, text=txt, command=cmd, bg=bg, fg=fg, bd=0,
                          padx=12, pady=6, cursor="hand2", activebackground=AC, activeforeground="#060915")
            b.pack(side=tk.LEFT, padx=(0,8))
        btn("Load",    self._open)
        btn("Save",    self._save)
        btn("F4 Check",self._check)
        btn("F5 Gen Chat HTML", self._gen, primary=True)
        btn("Help",    self._help)
        self.lbl_meta = tk.Label(bar, text="", bg=P1, fg=MU)
        self.lbl_meta.pack(side=tk.RIGHT)

    def _statusbar(self):
        sb = tk.Frame(self.root, bg=BG, padx=10, pady=6)
        sb.pack(fill=tk.X)
        self.svar = tk.StringVar(value="")
        tk.Label(sb, textvariable=self.svar, bg=BG, fg=MU, anchor="w").pack(fill=tk.X)

    def _set_text(self, s):
        self.editor.delete("1.0", tk.END)
        self.editor.insert("1.0", s)
        self._update_lineno()

    def _get_text(self):
        return self.editor.get("1.0", tk.END).rstrip("\n")

    def _upd_meta(self):
        name = os.path.basename(self.cur_file) if self.cur_file else "untitled"
        self.lbl_meta.config(text=f"{APP_TITLE}  ·  {name}  ·  {datetime.now().strftime('%H:%M:%S')}")

    def _log(self, msg):
        t = datetime.now().strftime("%H:%M:%S")
        self.logbox.config(state=tk.NORMAL)
        self.logbox.insert(tk.END, f"[{t}]  {msg}\n")
        self.logbox.config(state=tk.DISABLED)
        self.logbox.see(tk.END)

    def _set_status(self, s):
        self.svar.set(s)

    def _clear_log(self):
        self.logbox.config(state=tk.NORMAL)
        self.logbox.delete("1.0", tk.END)
        self.logbox.config(state=tk.DISABLED)

    def _new(self):
        if not messagebox.askyesno("New", "Discard current content?"): return
        self.cur_file = ""
        self._set_text(DEFAULT_PATTERN)
        self._upd_meta()
        self._set_status("New pattern loaded")
        self._log("New file")

    def _open(self):
        p = filedialog.askopenfilename(filetypes=[("Text","*.txt *.pat"),("All","*.*")])
        if not p: return
        try: s = open(p, encoding="utf-8").read()
        except Exception: s = open(p, encoding="utf-8-sig").read()
        self.cur_file = p
        self._set_text(s)
        self._upd_meta()
        self._set_status(f"Loaded: {os.path.basename(p)}")
        self._log(f"Loaded: {p}")

    def _save(self):
        if not self.cur_file: return self._save_as()
        open(self.cur_file, "w", encoding="utf-8").write(self._get_text())
        self._upd_meta()
        self._set_status(f"Saved: {os.path.basename(self.cur_file)}")
        self._log(f"Saved: {self.cur_file}")

    def _save_as(self):
        p = filedialog.asksaveasfilename(defaultextension=".txt",
            filetypes=[("Text","*.txt"),("All","*.*")],
            initialfile=os.path.basename(self.cur_file) if self.cur_file else "patterns.txt")
        if not p: return
        self.cur_file = p
        self._save()

    def _check(self):
        text = self._get_text()
        lines = text.splitlines()
        issues = []
        has_final = False

        for i, line in enumerate(lines, 1):
            s = line.strip()
            if not s or s == "---" or s.startswith("#!") or s.startswith("# "):
                continue

            if s.startswith("#") and "=" in s:
                if "@" not in s or "!" not in s:
                    issues.append(f"บรรทัด {i}: ต้องมี @ไฟล์!ชีต!  เช่น #0=@FILE*.xlsx!Sheet*!")
                if "$" not in s:
                    issues.append(f"บรรทัด {i}: ต้องมี ${{Key}}")
                if ";" not in s and "||" not in s:
                    issues.append(f"บรรทัด {i}: ควรมี ; หรือ || แบ่งเงื่อนไข")
                continue

            if s.startswith("@"):
                has_final = True
                if "!" not in s:
                    issues.append(f"บรรทัด {i}: Final ต้องมี ! คั่น  เช่น @FILE*.xlsx!Sheet*!")
                if "$" not in s:
                    issues.append(f"บรรทัด {i}: Final ต้องมี ${{Key}}")
                continue

            if len(s) > 2 and not s.startswith("["):
                issues.append(f"บรรทัด {i}: รูปแบบไม่รู้จัก → '{s[:40]}'")

        if not has_final:
            issues.append("ไม่พบ Final Template (@ไฟล์!ชีต!...)")

        if issues:
            report = "พบปัญหา " + str(len(issues)) + " จุด:\n\n• " + "\n• ".join(issues)
            messagebox.showwarning("Check Pattern", report)
            self._log(f"Check: {len(issues)} issue(s)")
            self._set_status(f"Check: พบ {len(issues)} ปัญหา")
        else:
            messagebox.showinfo("Check Pattern", "✓ Pattern ผ่านการตรวจ DSL")
            self._log("Check: OK ✓")
            self._set_status("Check: ผ่าน ✓")

    def _gen(self):
        self._log("=" * 50)
        self._log("Gen Chat HTML started...")

        def run():
            try:
                dsl_text = self._get_text()
                db = load_dsl_data(dsl_text, log_fn=lambda m: self.root.after(0, lambda m=m: self._log(m)))
                gen_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S")

                global HTML_TEMPLATE
                html_text = build_html(db, dsl_text, gen_time)

                bd = base_dir()
                out_dir = os.path.join(bd, OUT_DIR)
                os.makedirs(out_dir, exist_ok=True)
                fn  = "chat_" + datetime.now().strftime("%Y%m%d_%H%M%S") + ".html"
                fp  = os.path.join(out_dir, fn)

                with open(fp, "w", encoding="utf-8") as f:
                    f.write(html_text)

                total_rows = sum(len(sh_data["rows"]) for fn in db for sh_data in db[fn].values())
                msg = f"Done: {total_rows:,} rows loaded  →  {fn}"

                def done():
                    self._log(msg)
                    self._log(f"Path: {fp}")
                    self._set_status(msg)
                    self._upd_meta()
                    webbrowser.open("file://" + fp.replace("\\\\", "/"))
                    messagebox.showinfo("Gen Complete", f"✓ {msg}\n\nไฟล์: {fn}\nโฟลเดอร์: {OUT_DIR}")
                self.root.after(0, done)
            except Exception as e:
                import traceback
                tb = traceback.format_exc()
                self.root.after(0, lambda e=e: self._log(f"ERROR: {e}"))
                self.root.after(0, lambda tb=tb: self._log(tb))
                self.root.after(0, lambda e=e: messagebox.showerror("Error", str(e)))

        threading.Thread(target=run, daemon=True).start()

    def _help(self):
        w = tk.Toplevel(self.root)
        w.title("DSL Reference / คู่มือ Pattern")
        w.geometry("800x600")
        w.configure(bg=BG)
        txt = scrolledtext.ScrolledText(w, bg=P1, fg=TX, font=MONO, bd=0)
        txt.pack(fill=tk.BOTH, expand=True, padx=10, pady=10)
        txt.insert(tk.END, DSL_HELP)
        txt.config(state=tk.DISABLED)

    def _about(self):
        msg = f"{APP_TITLE}\nVersion: {VERSION} By zoneidt@gmail.com\n\nProduction Grade Engine for Dynamic DSL to Chatbot.\n"
        msg += "• High Performance Excel Data Matching\n• Dynamic Fallback Priority & Condition Engine\n• Batch Input Supported"
        messagebox.showinfo("About", msg)

    def _open_outdir(self):
        p = os.path.join(base_dir(), OUT_DIR)
        os.makedirs(p, exist_ok=True)
        try:
            if sys.platform == "win32": os.startfile(p)
            elif sys.platform == "darwin": os.system(f'open "{p}"')
            else: os.system(f'xdg-open "{p}"')
        except Exception: pass

    def _build_cmd(self):
        msg = (
            "สร้าง .exe ด้วย PyInstaller\n\n"
            "1. pip install pyinstaller\n"
            "2. pyinstaller --onefile --windowed --name ExcelToChatbot_v0.1b generator_v0.1b.py\n"
            "3. ไฟล์จะอยู่ที่ dist/ExcelToChatbot_v0.1b.exe\n\n"
            "นำไฟล์ .exe ไปวางร่วมกับไฟล์ Excel ได้เลย"
        )
        messagebox.showinfo("Build .exe", msg)

if __name__ == "__main__":
    root = tk.Tk()
    global HTML_TEMPLATE
    HTML_TEMPLATE = '<!doctype html>\n<html lang="th">\n<head>\n<meta charset="utf-8">\n<meta name="viewport" content="width=device-width,initial-scale=1">\n<title>Excel To Chatbot v0.1b</title>\n<style>\n*{box-sizing:border-box;margin:0;padding:0}\n:root{--bg:#06090f;--p1:#0a0f1e;--p2:#0c1525;--bd:#1a2540;--tx:#dde3f0;--mu:#8892a8;--ac:#3b9eff;--gn:#22c55e;--rd:#f87171;--yn:#f59e0b;--font:system-ui,-apple-system,\'Segoe UI\',sans-serif;--mono:\'Consolas\',\'Menlo\',monospace}\nhtml,body{height:100%;background:var(--bg);color:var(--tx);font-family:var(--font)}\n.app{display:flex;flex-direction:column;height:100vh}\n.hdr{display:flex;align-items:center;gap:10px;padding:8px 14px;background:rgba(6,9,15,.9);border-bottom:1px solid var(--bd);backdrop-filter:blur(14px);flex-shrink:0;position:sticky;top:0;z-index:10}\n.logo{background:linear-gradient(135deg,var(--ac),#f50505);color:#fff;font-weight:900;padding:3px 10px;border-radius:999px;font-size:12px;letter-spacing:.04em}\n.hdr-t{font-weight:700;font-size:13px}\n.hdr-s{font-size:10px;color:var(--mu);margin-top:1px}\n.hdr-r{margin-left:auto;display:flex;align-items:center;gap:8px}\n.hdr-time{font-size:10px;color:var(--mu)}\n.hbtn{background:rgba(255,255,255,.05);border:1px solid rgba(255,255,255,.12);color:var(--mu);padding:5px 10px;border-radius:8px;cursor:pointer;font-size:11px}\n.hbtn:hover{background:rgba(255,255,255,.1);color:var(--tx)}\n.body{display:flex;flex:1;min-height:0}\n.chat-wrap{display:flex;flex-direction:column;flex:1;min-width:0}\n.msgs{flex:1;overflow-y:auto;padding:12px 16px;scroll-behavior:smooth;scrollbar-width:thin;scrollbar-color:var(--bd) transparent}\n.msgs::-webkit-scrollbar{width:4px}.msgs::-webkit-scrollbar-thumb{background:var(--bd);border-radius:99px}\n.msg{display:flex;gap:9px;margin-bottom:12px;animation:fi .16s ease}\n@keyframes fi{from{opacity:0;transform:translateY(4px)}to{opacity:1;transform:none}}\n.msg.usr{flex-direction:row-reverse}\n.av{width:28px;height:28px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-weight:700;font-size:11px;flex-shrink:0;margin-top:2px}\n.av.b{background:rgba(59,158,255,.12);border:1px solid rgba(59,158,255,.4);color:#7cbfff}\n.av.u{background:rgba(34,197,94,.12);border:1px solid rgba(34,197,94,.4);color:#86efac}\n.bub{max-width:82%;padding:9px 12px;border-radius:14px;border:1px solid var(--bd);font-size:13px;line-height:1.65;word-break:break-word}\n.bub.b{background:var(--p2);border-radius:3px 14px 14px 14px}\n.bub.u{background:rgba(22,101,52,.25);border-color:rgba(34,197,94,.25);border-radius:14px 3px 14px 14px;color:#bbf7d0}\n.bub.e{border-color:rgba(248,113,113,.35);background:rgba(248,113,113,.08);color:#fca5a5}\n.bub.w{border-color:rgba(245,158,11,.35);background:rgba(245,158,11,.08);color:#fcd34d}\n.bub-top{display:flex;align-items:center;gap:7px;margin-bottom:6px}\n.tg{font-size:10px;padding:1px 7px;border-radius:999px;border:1px solid rgba(255,255,255,.14);color:var(--mu)}\n.tg.ok{border-color:rgba(34,197,94,.4);color:#86efac;background:rgba(22,163,74,.15)}\n.tg.er{border-color:rgba(248,113,113,.4);color:#fca5a5;background:rgba(220,38,38,.12)}\n.tg.wn{border-color:rgba(245,158,11,.4);color:#fcd34d;background:rgba(180,83,9,.12)}\n.cbtn{margin-left:auto;border:1px solid rgba(255,255,255,.14);background:transparent;color:var(--mu);border-radius:7px;padding:3px 9px;font-size:10px;cursor:pointer;flex-shrink:0}\n.cbtn:hover{background:rgba(255,255,255,.08);color:var(--tx)}\npre.bt{white-space:pre-wrap;font-family:var(--mono);font-size:12.5px;line-height:1.55;tab-size:2}\n.inp-wrap{padding:8px 12px 12px;background:rgba(6,9,15,.9);border-top:1px solid var(--bd);flex-shrink:0}\n.inp-row{display:flex;gap:8px;align-items:flex-end;background:var(--p2);border:1px solid var(--bd);border-radius:12px;padding:6px 6px 6px 12px;transition:.15s}\n.inp-row:focus-within{border-color:rgba(59,158,255,.6);box-shadow:0 0 0 1px rgba(59,158,255,.2)}\n#inp{flex:1;background:transparent;border:none;outline:none;color:var(--tx);font-family:var(--mono);font-size:13px;padding:4px 0;resize:none;max-height:96px;min-height:26px;line-height:1.55;scrollbar-width:thin}\n#inp::placeholder{color:var(--mu)}\n.sbtn{background:linear-gradient(135deg,var(--ac),#0066cc);border:none;color:#fff;font-weight:700;font-size:12px;padding:8px 14px;border-radius:9px;cursor:pointer;white-space:nowrap;flex-shrink:0}\n.sbtn:hover{opacity:.88}\n.hint{font-size:10px;color:var(--mu);margin-top:5px;text-align:center}\n.sb{width:256px;flex-shrink:0;border-left:1px solid var(--bd);background:var(--p1);display:flex;flex-direction:column;overflow-y:auto;scrollbar-width:thin;scrollbar-color:var(--bd) transparent}\n.sb::-webkit-scrollbar{width:4px}.sb::-webkit-scrollbar-thumb{background:var(--bd);border-radius:99px}\n.sb-sec{padding:11px 13px;border-bottom:1px solid var(--bd)}\n.sb-ttl{font-size:10px;letter-spacing:.1em;text-transform:uppercase;color:var(--mu);margin-bottom:7px;font-weight:600}\n.st-row{display:flex;justify-content:space-between;align-items:center;font-size:11.5px;padding:3px 0}\n.st-v{font-weight:600;color:var(--ac)}\n.st-v.ok{color:var(--gn)}.st-v.bad{color:var(--rd)}\n.src-c{background:var(--p2);border:1px solid var(--bd);border-radius:10px;padding:8px 10px;margin-bottom:7px}\n.src-n{font-size:11.5px;font-weight:700;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}\n.src-m{font-size:10px;color:var(--mu);margin-top:3px}\n.pill{display:inline-block;padding:1px 7px;border-radius:999px;font-size:10px;margin-left:4px;border:1px solid rgba(255,255,255,.14);color:var(--mu)}\n.pill.ok{border-color:rgba(34,197,94,.35);color:#86efac}\n.cpbtn{width:100%;padding:7px;border-radius:9px;border:1px solid rgba(59,158,255,.35);background:rgba(59,158,255,.1);color:#7cbfff;cursor:pointer;font-size:11px;font-weight:600;margin-top:6px}\n.cpbtn:hover{background:rgba(59,158,255,.18)}\n.cp2{border-color:rgba(245,158,11,.35);background:rgba(245,158,11,.08);color:#fcd34d;margin-top:5px}\n.cp2:hover{background:rgba(245,158,11,.14)}\n.mob{display:none;align-items:center;gap:7px;padding:6px 12px;background:var(--p1);border-bottom:1px solid var(--bd);font-size:11px;color:var(--mu);cursor:pointer}\n.mob svg{transition:.2s}.mob.on svg{transform:rotate(180deg)}\n.toast{position:fixed;bottom:14px;left:50%;transform:translateX(-50%);background:var(--p1);border:1px solid rgba(255,255,255,.18);border-radius:999px;padding:8px 16px;font-size:11px;color:var(--tx);opacity:0;pointer-events:none;transition:opacity .2s;z-index:100}\n.toast.on{opacity:1}\n@media(max-width:820px){.sb{display:none}.sb.on{display:flex;position:fixed;top:0;right:0;height:100%;z-index:50;box-shadow:-6px 0 24px rgba(0,0,0,.6)}.mob{display:flex}}\n@media(max-width:480px){.bub{max-width:94%}.hdr-time{display:none}}\n</style>\n</head>\n<body>\n<div class="app">\n<header class="hdr">\n  <div class="logo">BETA</div>\n  <div>\n    <div class="hdr-t">Excel To Chatbot  <span style="font-size:10px;font-weight:400;color:var(--mu)">v0.1b</span></div>\n    <div class="hdr-s" id="genInfo">Dynamic DSL Engine</div>\n  </div>\n  <div class="hdr-r">\n    <div class="hdr-time" id="genTime"></div>\n    <button class="hbtn" onclick="clearChat()">Clear</button>\n  </div>\n</header>\n<div class="mob" id="mobBar" onclick="toggleSb()">\n  <svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M2 4l4 4 4-4"/></svg>\n  <span id="mobInfo">สรุป</span>\n</div>\n<div class="body">\n  <div class="chat-wrap">\n    <div class="msgs" id="msgs"></div>\n    <div class="inp-wrap">\n      <div class="inp-row">\n        <textarea id="inp" rows="1" enterkeyhint="send" autocomplete="off" autocorrect="off" spellcheck="false" placeholder="พิมพ์รหัสสินค้า แล้วกด Enter ส่ง  (รองรับการก๊อปปี้วางหลายบรรทัด)"></textarea>\n        <button class="sbtn" onclick="sendMsg()">ส่ง ↵</button>\n      </div>\n      <div class="hint">Enter = ส่ง | Shift+Enter = ขึ้นบรรทัดใหม่</div>\n    </div>\n  </div>\n  <aside class="sb" id="sb">\n    <div class="sb-sec">\n      <div class="sb-ttl">สถิติ Session</div>\n      <div class="st-row"><span>ค้นหาทั้งหมด</span><span class="st-v" id="stT">0</span></div>\n      <div class="st-row"><span>พบผลลัพธ์</span><span class="st-v ok" id="stO">0</span></div>\n      <div class="st-row"><span>ไม่พบ</span><span class="st-v bad" id="stM">0</span></div>\n    </div>\n    <div class="sb-sec">\n      <div class="sb-ttl">แหล่งข้อมูลที่โหลดเข้าสำเร็จ</div>\n      <div id="srcCards"></div>\n    </div>\n    <div class="sb-sec">\n      <button class="cpbtn" onclick="copyLast()">Copy ผลล่าสุด</button>\n      <button class="cpbtn cp2" onclick="copyAll()">Copy ทั้ง Session</button>\n    </div>\n    <div class="sb-sec" style="font-size:10px;color:var(--mu)"><div id="sbGenTime"></div></div>\n  </aside>\n</div>\n</div>\n<div class="toast" id="toast"></div>\n\n<script>\n/* ── INJECTED DATA ────────────────────────────────────────────── */\nconst DB = %%DB%%;\nconst RAW_DSL = %%DSL%%;\nconst META = %%META%%;\n\n/* ── STATE ────────────────────────────────────────────────────── */\nlet stats = {tot:0,ok:0,mis:0};\nlet lastTxt = \'\';\nlet session = [];\nlet PARSED_DSL = [];\n\n/* ── DEVICE DETECT ────────────────────────────────────────────── */\nconst _ua = navigator.userAgent;\nconst _isIOS = /iPhone|iPad|iPod/i.test(_ua);\n\n/* ── INIT ─────────────────────────────────────────────────────── */\nwindow.onload = () => {\n  const t = META && META.gen_time ? META.gen_time : \'\';\n  document.getElementById(\'genTime\').textContent = t ? \'Generated: \'+t : \'\';\n  document.getElementById(\'sbGenTime\').textContent = t ? \'Generated: \'+t : \'\';\n\n  let totalFiles = Object.keys(DB).length;\n  document.getElementById(\'genInfo\').textContent = (totalFiles > 0)\n      ? \'Standalone Chat · \' + totalFiles + \' files loaded\'\n      : \'Demo mode · ข้อมูลจำลอง\';\n\n  PARSED_DSL = parseDSL(RAW_DSL);\n  buildSrcCards();\n\n  if (totalFiles === 0) {\n    sysMsg(\'โหมด Demo: ไม่มีข้อมูลจริง ทดสอบโครงสร้าง DSL\');\n  } else {\n    sysMsg(\'Excel To Chatbot v0.1b พร้อมใช้งาน!\');\n  }\n\n  const inp = document.getElementById(\'inp\');\n  inp.addEventListener(\'keydown\', e => {\n    if (e.key === \'Enter\' && !e.shiftKey) {\n      e.preventDefault();\n      sendMsg();\n    }\n  });\n\n  inp.addEventListener(\'input\', () => {\n    inp.style.height = \'auto\';\n    inp.style.height = Math.min(inp.scrollHeight, 100) + \'px\';\n  });\n};\n\nwindow.onresize = () => { if (window.innerWidth > 820) document.getElementById(\'sb\').classList.remove(\'on\'); };\n\n/* ── DSL PARSER ───────────────────────────────────────────────── */\nfunction parseDSL(dslStr) {\n  let sections = dslStr.split(\'---\').map(s=>s.trim()).filter(Boolean);\n  let parsed = [];\n  for (let sec of sections) {\n    let lines = sec.split(\'\\n\').map(l=>l.trim()).filter(Boolean);\n    let rules = [];\n    let finalTpl = null;\n\n    for (let line of lines) {\n      if (line.startsWith(\'#\') && line.includes(\'=\')) {\n        let m = line.match(/^#(\\d+)\\s*=\\s*@([^!]+)!([^!]+)!\\$\\{([^}]+)\\}(.*)$/);\n        if (m) {\n          let branches = parseBranches(m[5].trim());\n          rules.push({ id: m[1], file: m[2], sheet: m[3], key: m[4], branches: branches });\n        }\n      } else if (line.startsWith(\'@\')) {\n        let endTxt = \'\'; let li = line; let em = li.match(/END;(.*)$/); if (em) { endTxt = em[1].trim(); li = li.replace(/\\s*END;.*$/, \'\'); }\n        let m = li.match(/^@([^!]+)!([^!]+)!(?:\\[)?\\$\\{([^}]+)\\}(.*?)(?:\\])?$/);\n        if (m) {\n          finalTpl = { file: m[1], sheet: m[2], key: m[3], template: "${" + m[3] + "}" + m[4], endText: endTxt };\n        }\n      }\n    }\n    if (finalTpl) parsed.push({rules, final: finalTpl});\n  }\n  return parsed;\n}\n\nfunction parseBranches(str) {\n  let branches = [];\n  let parts = str.split(\'||\');\n  let currentConds = [];\n  for (let p of parts) {\n    if (p.includes(\';\')) {\n      let [condStr, outStr] = p.split(\';\');\n      currentConds.push(condStr.trim());\n      branches.push({ conditions: currentConds, out: outStr });\n      currentConds = [];\n    } else {\n      currentConds.push(p.trim());\n    }\n  }\n  if (branches.length === 0 && str.trim()) {\n    branches.push({conditions: [], out: str.trim()});\n  }\n  return branches;\n}\n\n/* ── DB ENGINE ────────────────────────────────────────────────── */\nfunction matchGlob(str, pat) {\n  let regex = new RegExp(\'^\' + pat.replace(/\\./g, \'\\\\.\').replace(/\\*/g, \'.*\') + \'$\', \'i\');\n  return regex.test(str);\n}\n\nfunction findDbMatches(filePat, sheetPat) {\n  let matches = [];\n  for (let f in DB) {\n    if (matchGlob(f, filePat)) {\n      for (let s in DB[f]) {\n        if (matchGlob(s, sheetPat)) {\n          matches.push(DB[f][s]);\n        }\n      }\n    }\n  }\n  return matches;\n}\n\nfunction normalizeCol(name) {\n  return name.toLowerCase().replace(/[^a-z0-9ก-ฮ]/g, \'\');\n}\n\nfunction findColIdx(cols, namePat) {\n  let pat = normalizeCol(namePat);\n  for (let i = 0; i < cols.length; i++) {\n    if (normalizeCol(cols[i]).includes(pat)) return i;\n  }\n  // Fallbacks for common variations\n  if (pat.includes(\'item\') || pat.includes(\'part\') || pat.includes(\'material\') || pat.includes(\'no\') || pat.includes(\'code\')) {\n    for (let i = 0; i < cols.length; i++) {\n      let n = normalizeCol(cols[i]);\n      if (n.includes(\'item\') || n.includes(\'part\') || n.includes(\'material\') || n.includes(\'ordercode\') || n.includes(\'รหัส\')) return i;\n    }\n  }\n  return -1;\n}\n\nfunction getColVal(row, cols, colName) {\n  let idx = findColIdx(cols, colName);\n  let v = idx >= 0 ? (row[idx] || \'\') : \'\';\n  v = String(v).trim();\n\n  let cLow = colName.toLowerCase();\n  if (cLow.includes(\'ราคา\') || cLow.includes(\'price\')) {\n    let num = parseFloat(v.replace(/,/g, \'\'));\n    if (!isNaN(num)) {\n      v = num % 1 === 0 ? num.toLocaleString(\'th-TH\') : num.toLocaleString(\'th-TH\', {minimumFractionDigits: 2, maximumFractionDigits: 2});\n    }\n  } else if (!isNaN(parseFloat(v)) && v.includes(\'.\')) {\n    v = parseFloat(v).toString();\n  }\n  return v;\n}\n\nfunction evalCond(condStr, row, cols) {\n  if (!condStr) return true;\n  let ands = condStr.split(\'&&\');\n  for (let a of ands) {\n    a = a.trim();\n    if (!a) continue;\n    let m = a.match(/^\\{([^}]+)\\}\\s*(==|!=|>=|<=|>|<|NONULL)\\s*(.*)$/);\n    if (!m) continue;\n    let cName = m[1], op = m[2], val = m[3].trim();\n    let actual = getColVal(row, cols, cName);\n\n    if (op === \'NONULL\') {\n      if (!actual) return false;\n    } else if (val.endsWith(\'*\')) {\n      let pref = val.slice(0, -1).toLowerCase();\n      let actL = actual.toLowerCase();\n      if (op === \'==\') { if (!actL.startsWith(pref)) return false; }\n      if (op === \'!=\') { if (actL.startsWith(pref)) return false; }\n    } else if (val.includes(\'-\') && op === \'==\') {\n      let [min, max] = val.split(\'-\');\n      let num = parseFloat(actual.replace(/,/g, \'\'));\n      if (isNaN(num) || num < parseFloat(min) || num > parseFloat(max)) return false;\n    } else {\n      let numA = parseFloat(actual.replace(/,/g, \'\'));\n      let numV = parseFloat(val.replace(/,/g, \'\'));\n      let isNum = !isNaN(numV) && !isNaN(numA);\n\n      if (isNum && actual.trim() !== \'\') {\n        if (op === \'==\') { if(numA !== numV) return false; }\n        if (op === \'!=\') { if(numA === numV) return false; }\n        if (op === \'>\') { if(numA <= numV) return false; }\n        if (op === \'<\') { if(numA >= numV) return false; }\n        if (op === \'>=\') { if(numA < numV) return false; }\n        if (op === \'<=\') { if(numA > numV) return false; }\n      } else {\n        let aL = actual.toLowerCase(), vL = val.toLowerCase();\n        if (op === \'==\') { if(aL !== vL) return false; }\n        if (op === \'!=\') { if(aL === vL) return false; }\n      }\n    }\n  }\n  return true;\n}\n\nfunction execRule(rule, itemCode) {\n  let targets = findDbMatches(rule.file, rule.sheet);\n  for (let t of targets) {\n    let keyIdx = findColIdx(t.cols, rule.key);\n    if (keyIdx < 0) continue;\n    for (let row of t.rows) {\n      // 100% Exact match logic (avoids float mismatch)\n      let rowKey = String(row[keyIdx]).trim().replace(/\\.0$/, \'\').toUpperCase();\n      if (rowKey === itemCode) {\n        for (let branch of rule.branches) {\n          let pass = false;\n          if (branch.conditions.length === 0 || branch.conditions.join(\'\')===\'\') {\n            pass = true;\n          } else {\n            for (let cond of branch.conditions) {\n              if (evalCond(cond, row, t.cols)) { pass = true; break; }\n            }\n          }\n          if (pass) {\n            let out = branch.out;\n            out = out.replace(/\\$\\{([^}]+)\\}/g, (_, c) => {\n              if (c === rule.key) return String(row[keyIdx]).trim().replace(/\\.0$/, \'\');\n              return getColVal(row, t.cols, c);\n            });\n            out = out.replace(/\\{([^}#:]+)\\}/g, (_, c) => getColVal(row, t.cols, c));\n            return out;\n          }\n        }\n        return null;\n      }\n    }\n  }\n  return null;\n}\n\nfunction lookup(itemCode) {\n  itemCode = itemCode.trim().replace(/\\.0$/, \'\').toUpperCase();\n\n  for (let sec of PARSED_DSL) {\n    let targets = findDbMatches(sec.final.file, sec.final.sheet);\n    for (let t of targets) {\n      let keyIdx = findColIdx(t.cols, sec.final.key);\n      if (keyIdx < 0) continue;\n\n      for (let row of t.rows) {\n        let rowKey = String(row[keyIdx]).trim().replace(/\\.0$/, \'\').toUpperCase();\n        if (rowKey === itemCode) {\n\n          let ruleResults = {};\n          let forceOut = null;\n\n          // Execute internal rules targeting other files\n          for (let rule of sec.rules) {\n            let res = execRule(rule, itemCode);\n            if (res) {\n              let isOut = res.includes(\'OUT\');\n              let isBreak = res.includes(\'BREAK\');\n              res = res.replace(/OUT/g, \'\').replace(/BREAK/g, \'\').replace(/\\s+/g, \' \').trim();\n              ruleResults[rule.id] = res;\n              if (isOut) {\n                forceOut = res;\n                break;\n              }\n              if (isBreak) break;\n            }\n          }\n\n          if (forceOut) {\n            return {txt: forceOut.replace(/\\s{2,}/g, \' \').replace(/\\\\n/g, \'\\n\'), endText: sec.final.endText||\'\', type: \'w\'};\n          }\n\n          let finalStr = sec.final.template;\n          finalStr = finalStr.replace(/\\$\\{([^}]+)\\}/g, (_, c) => {\n            if (c === sec.final.key) return String(row[keyIdx]).trim().replace(/\\.0$/, \'\');\n            return getColVal(row, t.cols, c);\n          });\n          finalStr = finalStr.replace(/\\{([^}#:]+)\\}/g, (_, c) => getColVal(row, t.cols, c));\n\n          function evaluateToken(p) {\n            let m = p.match(/\\{#(\\d+)\\}/);\n            if (!m) return null;\n            let id = m[1];\n            if (!ruleResults[id]) return null;\n            let isWrapper = p.startsWith(\'{\') && p.endsWith(\'}\') && p !== `{#${id}}`;\n            let inner = isWrapper ? p.slice(1, -1) : p;\n            return inner.replace(`{#${id}}`, ruleResults[id]);\n          }\n\n          // Chain Fallbacks e.g. {#7}:{{#3} Weeks}:{#4}\n          let chainRegex = /(?:\\{#\\d+\\}|\\{[^{}]*\\{#\\d+\\}[^{}]*\\})(?:\\:(?:\\{#\\d+\\}|\\{[^{}]*\\{#\\d+\\}[^{}]*\\}))+/g;\n          finalStr = finalStr.replace(chainRegex, (match) => {\n            let parts = match.match(/\\{#\\d+\\}|\\{[^{}]*\\{#\\d+\\}[^{}]*\\}/g);\n            if (parts) {\n              for (let p of parts) {\n                let res = evaluateToken(p);\n                if (res !== null) return res;\n              }\n            }\n            return "";\n          });\n\n          // Standalone wrappers e.g. {{#3} Weeks}\n          finalStr = finalStr.replace(/\\{[^{}]*\\{#\\d+\\}[^{}]*\\}/g, (match) => {\n            return evaluateToken(match) || "";\n          });\n\n          // Simple {#N}\n          finalStr = finalStr.replace(/\\{#(\\d+)\\}/g, (match, id) => {\n            return ruleResults[id] || "";\n          });\n\n          return {txt: finalStr.replace(/\\s{2,}/g, \' \').trim().replace(/\\\\n/g, \'\\n\'), endText: sec.final.endText||\'\', type: \'ok\'};\n        }\n      }\n    }\n  }\n  return {txt: `ไม่พบข้อมูล: ${itemCode}`, type: \'e\'};\n}\n\n/* ── UI INTERACTION ────────────────────────────────────────────── */\nfunction sendMsg() {\n  try {\n    const inp = document.getElementById(\'inp\');\n    const raw = inp.value;\n    const trimmed = raw.trim();\n    if (!trimmed) return;\n\n    inp.value=\'\'; \n    inp.style.height=\'auto\';\n    addMsg(trimmed, \'usr\');\n\n    const codes = trimmed.split(/\\n/).map(l=>l.trim()).filter(Boolean);\n    if (!codes.length) return;\n\n    let combinedText = [];\n    let errCount = 0;\n    let endTexts = [];\n\n    codes.forEach(code => {\n      const res = lookup(code);\n      stats.tot++;\n      if (res.type===\'e\') { stats.mis++; errCount++; }\n      else { stats.ok++; }\n      combinedText.push(res.txt);\n      session.push(res.txt);\n      if (res.endText && !endTexts.includes(res.endText)) endTexts.push(res.endText);\n    });\n\n    updateStats();\n\n    const endSuffix = endTexts.length ? endTexts.join(\'\\n\').replace(/\\\\n/g, \'\\n\') : \'\';\n    const joinedTxt = combinedText.join(\'\\n\') + (endSuffix ? \'\\n\' + endSuffix : \'\');\n    lastTxt = joinedTxt;\n\n    let overallType = \'ok\';\n    if (errCount === codes.length) overallType = \'e\';\n    else if (errCount > 0) overallType = \'w\';\n\n    const tagText = codes.length > 1 ? codes.length + \' Items\' : codes[0];\n    addBotMsg(joinedTxt, overallType, tagText);\n\n  } catch (err) {\n    addBotMsg("System Error: " + err.message, "e", "ERROR");\n  }\n}\n\nfunction addMsg(text, role) {\n  const wrap = document.getElementById(\'msgs\');\n  const el = document.createElement(\'div\');\n  el.className = \'msg \' + role;\n  const av = document.createElement(\'div\'); av.className = \'av \' + (role===\'usr\'?\'u\':\'b\'); av.textContent = role===\'usr\' ? \'U\' : \'D\';\n  const bub = document.createElement(\'div\'); bub.className = \'bub \' + role; bub.textContent = text;\n  if (role===\'usr\') { el.appendChild(bub); el.appendChild(av); }\n  else { el.appendChild(av); el.appendChild(bub); }\n  wrap.appendChild(el);\n  el.scrollIntoView({behavior:\'smooth\',block:\'end\'});\n}\n\nfunction addBotMsg(text, type, code) {\n  const wrap = document.getElementById(\'msgs\');\n  const el = document.createElement(\'div\'); el.className = \'msg\';\n  const av = document.createElement(\'div\'); av.className = \'av b\'; av.textContent = \'D\';\n\n  const bub = document.createElement(\'div\');\n  const tc = type===\'e\'?\'e\':type===\'w\'?\'w\':\'\';\n  bub.className = \'bub b\' + (tc?\' \'+tc:\'\');\n\n  const top = document.createElement(\'div\'); top.className=\'bub-top\';\n  if (code) {\n    const tag = document.createElement(\'span\');\n    tag.className = \'tg \' + (type===\'e\'?\'er\':type===\'w\'?\'wn\':\'ok\');\n    tag.textContent = code; top.appendChild(tag);\n  }\n\n  const cp = document.createElement(\'button\');\n  cp.className = \'cbtn\'; cp.textContent = \'Copy\';\n  cp.addEventListener(\'click\', () => copyTxt(text));\n\n  top.appendChild(cp);\n  bub.appendChild(top);\n\n  const pre = document.createElement(\'pre\'); pre.className=\'bt\';\n  pre.textContent = text;\n  bub.appendChild(pre);\n\n  el.appendChild(av); el.appendChild(bub);\n  wrap.appendChild(el);\n  el.scrollIntoView({behavior:\'smooth\',block:\'end\'});\n}\n\nfunction sysMsg(text) {\n  const wrap = document.getElementById(\'msgs\');\n  const el = document.createElement(\'div\'); el.className=\'msg\';\n  const av = document.createElement(\'div\'); av.className=\'av b\'; av.textContent=\'i\';\n  const bub = document.createElement(\'div\'); bub.className=\'bub b\'; bub.style.color=\'var(--mu)\'; bub.style.fontSize=\'11px\'; bub.textContent=text;\n  el.appendChild(av); el.appendChild(bub);\n  wrap.appendChild(el);\n}\n\nfunction clearChat() {\n  document.getElementById(\'msgs\').innerHTML=\'\';\n  stats={tot:0,ok:0,mis:0}; lastTxt=\'\'; session=[];\n  updateStats(); sysMsg(\'Chat cleared · พร้อมใช้งาน\');\n}\n\nfunction updateStats() {\n  document.getElementById(\'stT\').textContent = stats.tot;\n  document.getElementById(\'stO\').textContent = stats.ok;\n  document.getElementById(\'stM\').textContent = stats.mis;\n  document.getElementById(\'mobInfo\').textContent = `${stats.ok}/${stats.tot} found`;\n}\n\nfunction buildSrcCards() {\n  const cont = document.getElementById(\'srcCards\');\n  if (Object.keys(DB).length === 0) {\n    cont.innerHTML = \'<div style="font-size:11px;color:var(--mu)">ไม่มีข้อมูล (Demo Mode)</div>\';\n    return;\n  }\n  let html = \'\';\n  for (let f in DB) {\n    for (let s in DB[f]) {\n      let rows = DB[f][s].rows.length;\n      html += `<div class="src-c"><div class="src-n">${esc(f)} <span class="pill ok">OK</span></div><div class="src-m">Sheet: ${esc(s)} · ${rows.toLocaleString()} rows</div></div>`;\n    }\n  }\n  cont.innerHTML = html;\n}\n\nfunction copyTxt(t) {\n  if (_isIOS) {\n    const el = document.createElement(\'textarea\');\n    el.value = t;\n    el.setAttribute(\'readonly\', \'\');\n    el.style.cssText = \'position:fixed;top:0;left:0;opacity:0;font-size:16px\';\n    document.body.appendChild(el);\n    const range = document.createRange();\n    range.selectNodeContents(el);\n    const sel = window.getSelection();\n    sel.removeAllRanges();\n    sel.addRange(range);\n    el.setSelectionRange(0, 999999);\n    let ok = false;\n    try { ok = document.execCommand(\'copy\'); } catch(e) {}\n    sel.removeAllRanges();\n    document.body.removeChild(el);\n    showToast(ok ? \'Copied ✓\' : \'กด Copy ด้วยตนเอง\');\n    return;\n  }\n  if (navigator.clipboard && navigator.clipboard.writeText) {\n    navigator.clipboard.writeText(t).then(()=>showToast(\'Copied ✓\')).catch(() => fallbackCopy(t));\n  } else {\n    fallbackCopy(t);\n  }\n}\nfunction fallbackCopy(t) {\n  const a=document.createElement(\'textarea\');\n  a.value=t; document.body.appendChild(a); a.select();\n  document.execCommand(\'copy\'); a.remove();\n  showToast(\'Copied ✓\');\n}\nfunction copyLast(){ lastTxt?copyTxt(lastTxt):showToast(\'ยังไม่มีผลลัพธ์\'); }\nfunction copyAll(){ session.length?copyTxt(session.join(\'\\n\')):showToast(\'ยังไม่มีผลลัพธ์\'); }\n\nlet _tt;\nfunction showToast(m){\n  const t=document.getElementById(\'toast\');\n  t.textContent=m; t.classList.add(\'on\');\n  clearTimeout(_tt); _tt=setTimeout(()=>t.classList.remove(\'on\'),1400);\n}\nfunction toggleSb(){\n  const sb=document.getElementById(\'sb\');\n  const mb=document.getElementById(\'mobBar\');\n  sb.classList.toggle(\'on\'); mb.classList.toggle(\'on\');\n}\nfunction esc(s){return String(s||\'\').replace(/&/g,\'&amp;\').replace(/</g,\'&lt;\').replace(/>/g,\'&gt;\');}\n</script>\n</body>\n</html>\n'
    App(root)
    root.mainloop()
