"""
DeployShip v2.0
===============
Deploy TanStack Start & Vite+React projects to any web host.
Drop your GitHub ZIP → get a ready-to-upload folder for FileZilla.

Compatible with: Lovable, Bolt, v0, Cursor, Replit, Vanilla Vite
Web hosts: All-Inkl, Strato, IONOS, 1&1, Hosteurope and more

A community tool by ihre-internetseite.com
NOT officially supported by Lovable.dev or any other platform.

NEW IN v2.0
-----------
• Asset-Localizer: downloads /__l5e/... Lovable-CDN assets into public/assets/
  and rewrites every code reference so they actually load on Strato/All-Inkl.
• Branding-Cleanup: strips gpteng.co script, lovable-tagger Vite plugin,
  data-lov-* attributes, <meta name="generator">, Lovable favicons,
  Bolt/v0/Cursor footers — keeps published sites neutral.
• 12 bugfixes (cross-platform open, Node version check, SSR handler signature,
  dynamic vite config, nested-route detection, htaccess SSR-safe, server-fn
  warning, HTTPS toggle, unique build folder, .txt error-report fallback,
  proper version compare, dynamic VITE_CONFIG).

Requirements:
  - Python 3.8+  (https://python.org)
  - Node.js 18+  (https://nodejs.org)

Build as .exe:
  pip install pyinstaller pillow
  pyinstaller --onefile --windowed --name deployship --add-data "logo.png;." deployship_v2.0.py
"""

import os
import re
import sys
import json
import uuid
import shutil
import platform
import subprocess
import threading
import webbrowser
import urllib.parse
import urllib.request
import zipfile
import tkinter as tk
from datetime import datetime
from tkinter import ttk, filedialog, messagebox
from pathlib import Path

# ── Python version check ───────────────────────────────────────────────────────
if sys.version_info < (3, 8):
    import tkinter as _tk
    _r = _tk.Tk(); _r.withdraw()
    messagebox.showerror(
        "Python too old",
        f"DeployShip requires Python 3.8 or newer.\n"
        f"Your version: {sys.version}\n\n"
        f"Download Python: https://python.org"
    )
    sys.exit(1)

# ── Node.js check ──────────────────────────────────────────────────────────────
def _check_nodejs():
    """Return (version_string, major_int) or (None, 0)."""
    try:
        cmd = ["node.exe", "--version"] if sys.platform == "win32" else ["node", "--version"]
        result = subprocess.run(cmd, capture_output=True, text=True, timeout=5)
        if result.returncode == 0:
            raw = result.stdout.strip()  # e.g. "v20.11.1"
            m = re.match(r"v?(\d+)", raw)
            major = int(m.group(1)) if m else 0
            return raw, major
    except Exception:
        pass
    return None, 0

_node_version, _node_major = _check_nodejs()
_NODE_OK = _node_version is not None and _node_major >= 18

_BASE_CLASS = tk.Tk
APP_VERSION    = "2.0.1"
APP_NAME       = "DeployShip"
KNOWN_VERSIONS = ["2.0.0", "2.0.1", "2.0.2", "2.1.0", "2.1.1", "2.2.0", "2.2.1", "2.3.0", "2.3.1"]
LATEST_KNOWN   = "2.3.1"
DONATION_URL   = "https://www.ihre-internetseite.com/deployship/"
ERROR_EMAIL    = "deployship@ihre-internetseite.com"

DISCLAIMER = (
    f"Community tool · Not affiliated with Lovable.dev · Donationware · No support guaranteed · "
    f"v{APP_VERSION}"
)

# ── Vite Config Template (two variants, chosen at runtime) ─────────────────────

VITE_CONFIG_LOVABLE = f"""\
// Auto-generated by {APP_NAME} v{APP_VERSION}
// For static web host deployment – do NOT use in Lovable.
import {{ defineConfig }} from "@lovable.dev/vite-tanstack-config";

export default defineConfig({{
  nitro: false,
  vite: {{
    build: {{ manifest: true }},
  }},
}});
"""

VITE_CONFIG_VANILLA = f"""\
// Auto-generated by {APP_NAME} v{APP_VERSION}
// Fallback config for projects without @lovable.dev/vite-tanstack-config.
import {{ defineConfig }} from "vite";
import react from "@vitejs/plugin-react";
import {{ tanstackRouter }} from "@tanstack/router-plugin/vite";

export default defineConfig({{
  base: "./",
  plugins: [tanstackRouter({{ target: "react", autoCodeSplitting: true }}), react()],
  build: {{
    manifest: true,
    outDir: "dist/client",
  }},
}});
"""

# .htaccess WITHOUT forced HTTPS (toggle adds it).
HTACCESS_BASE = """\
# Generated by DeployShip v{ver}
# Compatible with All-Inkl, Strato, IONOS, 1&1, Hosteurope and more

AddType application/javascript .js .mjs
AddType text/css .css
AddType image/svg+xml .svg
AddType image/webp .webp
AddType font/woff2 .woff2
AddType application/json .json

RewriteEngine On
{https_block}
# Serve pre-rendered SSR page if the request maps to a folder with index.html
RewriteCond %{{REQUEST_FILENAME}}/index.html -f
RewriteRule ^(.+?)/?$ /$1/index.html [L]

# SPA fallback for everything else
RewriteCond %{{REQUEST_FILENAME}} !-f
RewriteCond %{{REQUEST_FILENAME}} !-d
RewriteRule . /index.html [L]

<IfModule mod_expires.c>
  ExpiresActive On
  ExpiresByType text/html "access plus 0 seconds"
  ExpiresByType text/css "access plus 1 year"
  ExpiresByType application/javascript "access plus 1 year"
  ExpiresByType image/png "access plus 1 year"
  ExpiresByType image/jpeg "access plus 1 year"
  ExpiresByType image/svg+xml "access plus 1 year"
  ExpiresByType image/webp "access plus 1 year"
  ExpiresByType font/woff2 "access plus 1 year"
</IfModule>

<IfModule mod_deflate.c>
  AddOutputFilterByType DEFLATE text/html text/css application/javascript
</IfModule>
"""

HTTPS_BLOCK = """\
# Force HTTPS
RewriteCond %{HTTPS} off
RewriteRule ^ https://%{HTTP_HOST}%{REQUEST_URI} [L,R=301]
"""

# ── Branding patterns to scan/strip ────────────────────────────────────────────
BRANDING_PATTERNS = {
    "lovable_script":     re.compile(r'<script[^>]*\bsrc=["\'][^"\']*gpteng\.co[^"\']*["\'][^>]*>\s*</script>', re.I),
    "lovable_tagger_imp": re.compile(r'^\s*import\s+\{?[^}\n]*\}?\s*from\s+["\']lovable-tagger["\'].*$', re.M),
    "lovable_tagger_use": re.compile(r',?\s*(?:mode\s*={2,3}\s*["\']development["\']\s*&&\s*)?componentTagger\(\)\s*,?'),
    "lovable_data_attr":  re.compile(r'\sdata-lov-[a-z0-9-]+=["\'][^"\']*["\']'),
    "lovable_uploads":    re.compile(r'/lovable-uploads/[A-Za-z0-9_\-./]+'),
    "lovable_cdn_url":    re.compile(r'https?://[A-Za-z0-9.\-]*lovable(?:project|\.dev|\.app)[^\s"\'<>)]*'),
    "generator_meta":     re.compile(r'<meta[^>]*\bname=["\']generator["\'][^>]*>', re.I),
    "made_with":          re.compile(r'(?i)(made|built|powered)\s+with\s+(lovable|bolt|v0|cursor)[^<\n]{0,80}'),
    "bolt_ref":           re.compile(r'https?://(?:www\.)?bolt\.new[^\s"\'<>)]*'),
    "v0_ref":             re.compile(r'https?://(?:www\.)?v0\.dev[^\s"\'<>)]*'),
}

ASSET_URL_PATTERN = re.compile(r'/__l5e/assets-v1/[A-Za-z0-9_\-]+/[A-Za-z0-9_\-.%]+')
LOVABLE_UPLOAD_PATTERN = re.compile(r'/lovable-uploads/[A-Za-z0-9_\-./]+')

TEXT_EXTS = {".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".css", ".scss",
             ".html", ".htm", ".json", ".md", ".svg", ".txt", ".xml"}


# ── App ────────────────────────────────────────────────────────────────────────

class App(_BASE_CLASS):
    _ACCEPT_FILE = Path.home() / ".deployship_accepted"

    def __init__(self):
        super().__init__()
        self.title(f"{APP_NAME} v{APP_VERSION}")
        self.geometry("700x760")
        self.resizable(False, False)
        self.configure(bg="#0f172a")

        self.zip_path     = None
        self.build_thread = None
        self._log_buffer  = []
        self._last_error_file = None

        # User preferences (set via UI before build)
        self.force_https      = tk.BooleanVar(value=True)
        self.do_asset_local   = tk.BooleanVar(value=True)
        self.do_branding_wipe = tk.BooleanVar(value=True)

        self._build_ui()
        self._setup_dnd()
        self.after(100, self._check_disclaimer)

        if not _node_version:
            self._log("⚠ Node.js not found! Please install Node.js 18+ from https://nodejs.org")
            self.after(500, lambda: messagebox.showwarning(
                "Node.js missing",
                "Node.js was not found on your system.\n\n"
                "DeployShip requires Node.js 18 or newer.\n"
                "Download: https://nodejs.org"
            ))
        elif not _NODE_OK:
            self._log(f"⚠ Node.js {_node_version} is too old (need 18+).")
            self.after(500, lambda: messagebox.showwarning(
                "Node.js too old",
                f"Detected Node.js {_node_version}.\n"
                f"DeployShip requires Node.js 18 or newer.\n\n"
                f"Update: https://nodejs.org"
            ))
        else:
            self._log(f"ℹ Node.js {_node_version} detected (OK).")

    # ── UI ─────────────────────────────────────────────────────────────────────

    def _build_ui(self):
        # Header
        header = tk.Frame(self, bg="#0f172a")
        header.pack(fill="x", padx=30, pady=(16, 4))
        tk.Label(
            header, text=f"🚀  {APP_NAME}",
            font=("Segoe UI", 18, "bold"), fg="#38bdf8", bg="#0f172a"
        ).pack(side="left")
        tk.Label(
            header, text=f"v{APP_VERSION}",
            font=("Segoe UI", 9), fg="#64748b", bg="#0f172a"
        ).pack(side="left", padx=(8, 0), pady=(8, 0))

        tk.Label(
            self, text="Deploy TanStack & Vite+React projects to any classic web host",
            font=("Segoe UI", 9), fg="#94a3b8", bg="#0f172a"
        ).pack(padx=30, anchor="w")

        # Version warning frame (hidden until needed)
        self.version_frame = tk.Frame(self, bg="#451a03")
        self.version_label = tk.Label(
            self.version_frame, text="",
            font=("Segoe UI", 9), fg="#fed7aa", bg="#451a03", wraplength=620
        )
        self.version_label.pack(padx=12, pady=6)

        # Drop zone
        self.drop_frame = tk.Frame(
            self, bg="#1e3a5f", relief="groove", bd=2,
            width=640, height=130, cursor="hand2"
        )
        self.drop_frame.pack(pady=12, padx=30)
        self.drop_frame.pack_propagate(False)

        tk.Label(
            self.drop_frame,
            text="Select the project you want to deploy",
            font=("Segoe UI", 11, "bold"), fg="#e2e8f0", bg="#1e3a5f"
        ).pack(pady=(16, 4))

        btn_row = tk.Frame(self.drop_frame, bg="#1e3a5f")
        btn_row.pack()
        tk.Button(
            btn_row, text="📦  Select ZIP file",
            font=("Segoe UI", 10), fg="white", bg="#0284c7",
            activebackground="#0369a1", relief="flat",
            padx=14, pady=6, cursor="hand2",
            command=self._pick_file
        ).pack(side="left", padx=8)
        tk.Button(
            btn_row, text="📁  Select project folder",
            font=("Segoe UI", 10), fg="white", bg="#0f5132",
            activebackground="#0a3d26", relief="flat",
            padx=14, pady=6, cursor="hand2",
            command=self._pick_folder
        ).pack(side="left", padx=8)

        tk.Label(
            self.drop_frame,
            text="GitHub download (ZIP) or an already extracted folder",
            font=("Segoe UI", 8), fg="#94a3b8", bg="#1e3a5f"
        ).pack(pady=(6, 0))

        self.file_var = tk.StringVar(value="No file selected")
        tk.Label(
            self, textvariable=self.file_var,
            font=("Segoe UI", 9, "italic"), fg="#94a3b8", bg="#0f172a"
        ).pack()

        # ── Options ────────────────────────────────────────────────────────────
        opts = tk.LabelFrame(
            self, text=" Deployment options ",
            font=("Segoe UI", 9, "bold"), fg="#cbd5e1", bg="#0f172a",
            bd=1, relief="groove"
        )
        opts.pack(fill="x", padx=30, pady=(4, 6))

        for var, text in [
            (self.do_asset_local,   "📥  Download Lovable-CDN assets to /public/assets (recommended)"),
            (self.do_branding_wipe, "🧹  Strip platform branding (gpteng.co, lovable-tagger, data-lov-*, generator meta)"),
            (self.force_https,      "🔒  Force HTTPS in .htaccess (disable only if SSL is not yet active)"),
        ]:
            tk.Checkbutton(
                opts, text=text, variable=var,
                font=("Segoe UI", 9), fg="#cbd5e1", bg="#0f172a",
                activebackground="#0f172a", activeforeground="white",
                selectcolor="#1e293b", anchor="w"
            ).pack(fill="x", padx=10, pady=1)

        # Buttons row
        btn_frame = tk.Frame(self, bg="#0f172a")
        btn_frame.pack(pady=8)
        self.btn = tk.Button(
            btn_frame, text="▶  Start Build",
            font=("Segoe UI", 11, "bold"), fg="white", bg="#0284c7",
            activebackground="#0369a1", activeforeground="white",
            relief="flat", padx=20, pady=8, cursor="hand2",
            command=self._start_build, state="disabled"
        )
        self.btn.pack(side="left", padx=6)

        self.mail_btn = tk.Button(
            btn_frame, text="✉  Send Error Report",
            font=("Segoe UI", 9), fg="#94a3b8", bg="#1e293b",
            activebackground="#334155", activeforeground="white",
            relief="flat", padx=12, pady=8, cursor="hand2",
            command=self._send_error_report, state="disabled"
        )
        self.mail_btn.pack(side="left", padx=6)

        tk.Button(
            btn_frame, text="♥  Donate",
            font=("Segoe UI", 9), fg="#f472b6", bg="#1e293b",
            activebackground="#334155", activeforeground="#f472b6",
            relief="flat", padx=12, pady=8, cursor="hand2",
            command=lambda: webbrowser.open(DONATION_URL)
        ).pack(side="left", padx=6)

        # Progress bar
        style = ttk.Style()
        style.theme_use("default")
        style.configure("blue.Horizontal.TProgressbar", background="#38bdf8")
        self.progress = ttk.Progressbar(
            self, length=640, mode="indeterminate",
            style="blue.Horizontal.TProgressbar"
        )
        self.progress.pack(padx=30)

        # Log
        log_frame = tk.Frame(self, bg="#0f172a")
        log_frame.pack(fill="both", expand=True, padx=30, pady=(8, 0))
        scrollbar = tk.Scrollbar(log_frame)
        scrollbar.pack(side="right", fill="y")
        self.log_widget = tk.Text(
            log_frame, height=11, bg="#020617", fg="#4ade80",
            font=("Consolas", 9), relief="flat", state="disabled",
            yscrollcommand=scrollbar.set, wrap="word"
        )
        self.log_widget.pack(fill="both", expand=True)
        scrollbar.config(command=self.log_widget.yview)
        self.log_widget.tag_configure("error", foreground="#f87171")
        self.log_widget.tag_configure("warn",  foreground="#fbbf24")

        # Disclaimer footer
        disc = tk.Frame(self, bg="#0f172a")
        disc.pack(fill="x", padx=30, pady=(6, 8))
        tk.Label(
            disc,
            text="Community tool · Donationware · No support guaranteed · Not affiliated with Lovable.dev",
            font=("Segoe UI", 8), fg="#64748b", bg="#0f172a"
        ).pack(side="left")
        tk.Label(
            disc, text="ihre-internetseite.com",
            font=("Segoe UI", 8, "underline"), fg="#38bdf8", bg="#0f172a"
        ).pack(side="right")

    # ── Disclaimer ─────────────────────────────────────────────────────────────

    def _check_disclaimer(self):
        self._show_disclaimer()


    def _show_disclaimer(self):
        dlg = tk.Toplevel(self)
        dlg.title("Terms of Use – DeployShip")
        dlg.geometry("560x500")
        dlg.resizable(False, False)
        dlg.configure(bg="#0f172a")
        dlg.grab_set()
        dlg.focus_set()
        dlg.protocol("WM_DELETE_WINDOW", lambda: None)

        tk.Label(
            dlg, text="⚠  Please read before use",
            font=("Segoe UI", 13, "bold"), fg="#fbbf24", bg="#0f172a"
        ).pack(pady=(18, 6))

        text_frame = tk.Frame(dlg, bg="#1e293b", padx=16, pady=14)
        text_frame.pack(fill="both", expand=True, padx=20)

        disclaimer_text = (
            "DeployShip is a free community tool (donationware) developed to the best "
            "of our knowledge and belief.\n\n"
            "NO SUPPORT / NO WARRANTY\n"
            "Use at your own risk. The developers accept no liability for damages, "
            "data loss, or malfunctions that may result from using this tool.\n\n"
            "SECURITY NOTICE\n"
            "DeployShip executes Node.js code on your system and installs npm packages "
            "from the internet. Only use this tool with projects you trust. Always keep "
            "your operating system and antivirus software up to date.\n\n"
            "NOT OFFICIAL\n"
            "This tool is not affiliated with or endorsed by Lovable.dev, Bolt, v0, "
            "Cursor, Replit, or any other platform.\n\n"
            "By clicking \"I understand\", you confirm that you have read these terms "
            "and accept full responsibility for your use of this tool."
        )

        txt = tk.Text(
            text_frame, bg="#1e293b", fg="#cbd5e1",
            font=("Segoe UI", 9), relief="flat", wrap="word",
            height=17, state="normal"
        )
        txt.insert("1.0", disclaimer_text)
        txt.tag_configure("bold", font=("Segoe UI", 9, "bold"), foreground="#f1f5f9")
        # FIX #3: search for the actual English keywords used in the text.
        for keyword in ("NO SUPPORT / NO WARRANTY", "SECURITY NOTICE", "NOT OFFICIAL"):
            start = "1.0"
            while True:
                pos = txt.search(keyword, start, stopindex="end")
                if not pos:
                    break
                end = f"{pos}+{len(keyword)}c"
                txt.tag_add("bold", pos, end)
                start = end
        txt.config(state="disabled")
        txt.pack(fill="both", expand=True)

        def _accept():
            # (no persistence — disclaimer shows on every launch)
            dlg.destroy()

        tk.Button(
            dlg, text="✓  I understand – Continue to DeployShip",
            font=("Segoe UI", 10, "bold"), fg="white", bg="#0284c7",
            activebackground="#0369a1", relief="flat",
            padx=16, pady=10, cursor="hand2",
            command=_accept
        ).pack(pady=14)

        tk.Label(
            dlg, text="Community tool · ihre-internetseite.com",
            font=("Segoe UI", 8), fg="#475569", bg="#0f172a"
        ).pack(pady=(0, 8))

    def _setup_dnd(self):
        self._log("ℹ Click a button to select a ZIP file or project folder.")

    # ── Events ─────────────────────────────────────────────────────────────────

    def _pick_file(self):
        path = filedialog.askopenfilename(
            title="Select your project ZIP file",
            filetypes=[("ZIP archive", "*.zip")]
        )
        if path:
            self._set_source(path, is_folder=False)

    def _pick_folder(self):
        path = filedialog.askdirectory(title="Select your exported project folder")
        if path:
            self._set_source(path, is_folder=True)

    def _set_source(self, path, is_folder=False):
        self.zip_path = path
        self._is_folder = is_folder
        name = Path(path).name
        icon = "📁" if is_folder else "📦"
        self.file_var.set(f"✓  {icon}  {name}")
        self.btn.config(state="normal")
        self.mail_btn.config(state="disabled")

    def _start_build(self):
        if not self.zip_path:
            return
        self._log_buffer = []
        self._last_error_file = None
        if not hasattr(self, "_is_folder"):
            self._is_folder = False
        self.btn.config(state="disabled")
        self.mail_btn.config(state="disabled")
        self.progress.start(10)
        self.version_frame.pack_forget()
        self.build_thread = threading.Thread(target=self._run_build, daemon=True)
        self.build_thread.start()

    # FIX #11: write error report to .txt so we don't blow up mailto URL limits.
    def _send_error_report(self):
        log_text = "\n".join(self._log_buffer)
        header = (
            f"{APP_NAME} v{APP_VERSION} Error Report\n"
            f"OS: {platform.platform()}\n"
            f"Python: {sys.version.split()[0]}\n"
            f"Node: {_node_version or 'NOT INSTALLED'}\n"
            f"Date: {datetime.now().isoformat(timespec='seconds')}\n"
            f"{'='*60}\n\n"
        )
        full = header + log_text + (
            f"\n\n{'='*60}\n"
            f"NOTE: DeployShip is donationware. Bug reports welcome but no\n"
            f"support: {DONATION_URL}\n"
        )

        # Always write to disk for safety.
        try:
            target_dir = Path.home() / "Downloads"
            if not target_dir.exists():
                target_dir = Path.home()
            self._last_error_file = target_dir / f"deployship_error_{datetime.now():%Y%m%d_%H%M%S}.txt"
            self._last_error_file.write_text(full, encoding="utf-8")
            self._log(f"   📝 Error report saved: {self._last_error_file}")
        except Exception as e:
            self._log(f"   ⚠ Could not write error log: {e}")

        # Mailto: put the last ~60 log lines directly in the body (mailto URL limit ~2000 chars).
        log_lines = self._log_buffer[-60:] if len(self._log_buffer) > 60 else self._log_buffer
        mail_body = (
            f"{APP_NAME} v{APP_VERSION} Error Report\n"
            f"OS: {platform.platform()}\n"
            f"Python: {sys.version.split()[0]}\n"
            f"Node: {_node_version or 'NOT INSTALLED'}\n"
            f"Date: {datetime.now().isoformat(timespec='seconds')}\n"
            f"{'='*60}\n\n"
            + "\n".join(log_lines)
            + f"\n\n{'='*60}\n"
            f"Describe what you did before the error:\n\n"
        )
        subject = urllib.parse.quote(f"Error Report {APP_NAME} v{APP_VERSION}")
        try:
            webbrowser.open(f"mailto:{ERROR_EMAIL}?subject={subject}&body={urllib.parse.quote(mail_body)}")
        except Exception:
            pass

        if self._last_error_file:
            self._open_in_filemanager(self._last_error_file.parent)

    # FIX #1: cross-platform folder/file opener.
    def _open_in_filemanager(self, path: Path):
        try:
            if sys.platform == "win32":
                os.startfile(str(path))  # type: ignore[attr-defined]
            elif sys.platform == "darwin":
                subprocess.Popen(["open", str(path)])
            else:
                subprocess.Popen(["xdg-open", str(path)])
        except Exception as e:
            self._log(f"   ⚠ Could not open {path}: {e}")

    # ── Build ───────────────────────────────────────────────────────────────────

    def _run_build(self):
        try:
            source = Path(self.zip_path)
            is_folder = getattr(self, "_is_folder", False)

            if is_folder:
                self._log("\n── Step 1: Using project folder ──")
                project_dir = self._find_project_dir(source)
                self._log(f"   Project: {project_dir.name}/")
            else:
                # FIX #10: unique build folder per run (timestamp + short uuid).
                zip_path = source
                stamp = datetime.now().strftime("%Y%m%d_%H%M%S")
                work_dir = zip_path.parent / f"_delete_after_upload_{zip_path.stem}_{stamp}"
                work_dir.mkdir()
                self._log("\n── Step 1: Extracting ZIP ──")
                with zipfile.ZipFile(zip_path, "r") as z:
                    z.extractall(work_dir)
                project_dir = self._find_project_dir(work_dir)
                self._log(f"   Project: {project_dir.name}/")

            # 2. Detect stack
            self._log("\n── Step 2: Detecting project stack ──")
            is_tanstack, has_lovable_plugin = self._detect_stack(project_dir)

            # FIX #8: warn on createServerFn projects — they need a runtime, not static hosting.
            self._check_server_functions(project_dir)

            # NEW: Asset-Localizer (run before branding wipe so we don't lose URLs)
            if self.do_asset_local.get():
                self._log("\n── Step 3a: Localizing CDN assets ──")
                self._localize_assets(project_dir, source if not is_folder else None)
            else:
                self._log("\n── Step 3a: Skipping asset localization (option disabled) ──")

            # NEW: Branding cleanup
            if self.do_branding_wipe.get():
                self._log("\n── Step 3b: Stripping platform branding ──")
                self._strip_branding(project_dir)
            else:
                self._log("\n── Step 3b: Skipping branding cleanup (option disabled) ──")

            # 4. Inject deploy config
            self._log("\n── Step 4: Injecting deployment config ──")
            public_dir = project_dir / "public"
            public_dir.mkdir(exist_ok=True)
            htaccess = HTACCESS_BASE.format(
                ver=APP_VERSION,
                https_block=HTTPS_BLOCK if self.force_https.get() else "# (HTTPS redirect disabled — enable in DeployShip options)"
            )
            (public_dir / ".htaccess").write_text(htaccess, encoding="utf-8")
            self._log(f"   ✓ .htaccess (HTTPS={'on' if self.force_https.get() else 'off'})")

            if is_tanstack:
                # FIX #5: pick config that matches what the project actually has.
                cfg = VITE_CONFIG_LOVABLE if has_lovable_plugin else VITE_CONFIG_VANILLA
                (project_dir / "vite.config.deployship.ts").write_text(cfg, encoding="utf-8")
                self._log(f"   ✓ vite.config.deployship.ts ({'lovable' if has_lovable_plugin else 'vanilla'} variant)")
            else:
                self._patch_vite_config_base(project_dir)

            # 5. npm install
            self._log("\n── Step 5: Installing dependencies ──")
            self._run_cmd(["npm", "install"], project_dir)

            # 6. Build
            self._log("\n── Step 6: Building ──")
            if is_tanstack:
                self._run_cmd(
                    ["npx", "vite", "build", "--config", "vite.config.deployship.ts"],
                    project_dir
                )
            else:
                self._run_cmd(["npx", "vite", "build"], project_dir)

            # 7. SSR render
            if is_tanstack:
                self._log("\n── Step 7: Rendering pages via SSR ──")
                self._render_via_ssr(project_dir)

            # 8. Post-build sweep (data-lov-* survives lovable-tagger removal sometimes)
            if self.do_branding_wipe.get():
                self._log("\n── Step 8: Post-build sweep ──")
                self._post_build_sweep(project_dir)

            # 9. Upload folder
            self._log("\n── Step 9: Creating upload folder ──")
            output = self._find_output(project_dir)
            upload_dir = source.parent / f"upload_this_{source.stem}"
            if upload_dir.exists():
                shutil.rmtree(upload_dir)
            shutil.copytree(output, upload_dir)
            self._log(f"\n✅  DONE! Upload folder: {upload_dir.name}/")
            self._log("   Upload its contents via FileZilla to your web server.")

            self.after(500, lambda: self._open_in_filemanager(upload_dir))
            self.after(600, lambda: messagebox.showinfo(
                "Build successful! 🎉",
                f"Your site is ready to deploy!\n\n"
                f"Folder:\n{upload_dir}\n\n"
                f"Upload its contents via FileZilla.\n\n"
            f"Support DeployShip: {DONATION_URL}\n"
            ))

        except Exception as e:
            self._log(f"\n❌  Error: {e}")
            self.after(0, lambda: self._on_error(str(e)))
        finally:
            self.after(0, lambda: self.progress.stop())
            self.after(0, lambda: self.btn.config(state="normal"))
            self.after(0, lambda: self.mail_btn.config(state="normal"))

    def _on_error(self, msg: str):
        result = messagebox.askyesno(
            "Build failed",
            f"Something went wrong:\n{msg}\n\n"
            f"DeployShip is donationware — we don't offer support,\n"
            f"but we'd love to fix bugs!\n\n"
            f"Save & send an error report?"
        )
        if result:
            self._send_error_report()

    # ── Stack Detection ─────────────────────────────────────────────────────────

    def _detect_stack(self, project_dir: Path):
        """Returns (is_tanstack, has_lovable_plugin)."""
        pkg_path = project_dir / "package.json"
        if pkg_path.exists():
            try:
                pkg = json.loads(pkg_path.read_text(encoding="utf-8"))
                deps = {**pkg.get("dependencies", {}), **pkg.get("devDependencies", {})}
                has_lovable = "@lovable.dev/vite-tanstack-config" in deps
                is_tanstack = "@tanstack/react-start" in deps or has_lovable
                if is_tanstack:
                    self._log(f"   ✓ TanStack Start detected (lovable-plugin: {has_lovable})")
                    self._check_lovable_version(project_dir)
                else:
                    self._log("   ✓ Vite + React detected (classic)")
                return is_tanstack, has_lovable
            except Exception:
                pass
        if (project_dir / "wrangler.jsonc").exists():
            self._log("   ✓ TanStack Start detected (wrangler.jsonc found)")
            return True, False
        self._log("   ⚠ Stack unclear – assuming Vite+React")
        return False, False

    # FIX #8
    def _check_server_functions(self, project_dir: Path):
        src = project_dir / "src"
        if not src.exists():
            return
        hits = 0
        for f in src.rglob("*"):
            if f.suffix in (".ts", ".tsx", ".js", ".jsx") and f.is_file():
                try:
                    if "createServerFn" in f.read_text(encoding="utf-8", errors="ignore"):
                        hits += 1
                except Exception:
                    pass
                if hits >= 1:
                    break
        if hits:
            self._log("   ⚠ Project uses createServerFn — server functions WILL NOT work on static hosting.")
            self._log("     Affected features (auth, DB queries, server logic) will fail at runtime.")
            self.after(0, lambda: messagebox.showwarning(
                "Server functions detected",
                "This project uses createServerFn (TanStack server functions).\n\n"
                "Static web hosts like All-Inkl or Strato cannot execute server code.\n"
                "All createServerFn calls will fail at runtime.\n\n"
                "DeployShip will continue, but pages depending on server logic "
                "will not work after deployment."
            ))

    # FIX #12: proper version comparison.
    def _check_lovable_version(self, project_dir: Path):
        pkg_path = project_dir / "package.json"
        try:
            pkg = json.loads(pkg_path.read_text(encoding="utf-8"))
            dev_deps = pkg.get("devDependencies", {})
            version = dev_deps.get("@lovable.dev/vite-tanstack-config", "")
            clean = version.lstrip("^~>=")
            self._log(f"   @lovable.dev/vite-tanstack-config: {version or '(not found)'}")
            if not clean:
                return
            try:
                from packaging.version import Version
                is_newer = Version(clean) > Version(LATEST_KNOWN)
            except Exception:
                # Fallback: numeric tuple compare, NOT string compare.
                def _tup(v):
                    return tuple(int(x) for x in re.findall(r"\d+", v))
                is_newer = clean not in KNOWN_VERSIONS and _tup(clean) > _tup(LATEST_KNOWN)
            if is_newer:
                msg = (
                    f"⚠  New Lovable version detected ({clean})!\n"
                    f"   Tested up to {LATEST_KNOWN}. Check for updates: {DONATION_URL}\n"
                )
                self._log(msg)
                self.after(0, lambda: self._show_version_warning(clean))
            else:
                self._log("   ✓ Compatible version")
        except Exception:
            self._log("   ⚠ Could not read package.json")

    def _show_version_warning(self, version: str):
        self.version_label.config(
            text=f"⚠  New Lovable version detected ({version}) – "
                 f"tested up to {LATEST_KNOWN}. "
                 f"Check for updates: {DONATION_URL}"
        )
        self.version_frame.pack(fill="x", before=self.drop_frame)

    # ── Asset Localizer (NEW v2.0) ──────────────────────────────────────────────

    def _localize_assets(self, project_dir: Path, source_zip: Path = None):
        """Scan code for /__l5e/... and /lovable-uploads/..., download to public/assets,
        then rewrite all code references to local paths."""
        # 1. Collect all URLs
        cdn_urls = set()
        upload_urls = set()
        files_to_scan = self._collect_text_files(project_dir)

        for f in files_to_scan:
            try:
                content = f.read_text(encoding="utf-8", errors="ignore")
            except Exception:
                continue
            for m in ASSET_URL_PATTERN.findall(content):
                cdn_urls.add(m)
            for m in LOVABLE_UPLOAD_PATTERN.findall(content):
                upload_urls.add(m)

        # Also collect from .asset.json pointer files
        asset_json_files = list((project_dir / "src").rglob("*.asset.json")) if (project_dir / "src").exists() else []
        for af in asset_json_files:
            try:
                data = json.loads(af.read_text(encoding="utf-8"))
                if isinstance(data, dict) and "url" in data:
                    if "/__l5e/" in data["url"]:
                        cdn_urls.add(data["url"])
            except Exception:
                pass

        total = len(cdn_urls) + len(upload_urls)
        if total == 0:
            self._log("   ✓ No external CDN assets found")
            return

        self._log(f"   Found {len(cdn_urls)} Lovable-CDN assets, {len(upload_urls)} legacy uploads")

        # 2. Figure out base URL to download from
        base_url = self._guess_lovable_base_url(project_dir)
        if not base_url:
            base_url = "https://preview--unknown.lovable.app"
            self._log(f"   ⚠ Could not detect project URL — trying {base_url}")
            self._log(f"     If downloads fail, set the correct project URL in package.json 'homepage'.")
        else:
            self._log(f"   Source: {base_url}")

        # 3. Download
        assets_out = project_dir / "public" / "assets" / "localized"
        assets_out.mkdir(parents=True, exist_ok=True)
        url_map = {}
        failed = []

        for url_path in sorted(cdn_urls | upload_urls):
            local_name = self._safe_filename(url_path)
            local_rel = f"/assets/localized/{local_name}"
            target = assets_out / local_name
            if target.exists():
                url_map[url_path] = local_rel
                continue
            full_url = base_url.rstrip("/") + url_path
            try:
                req = urllib.request.Request(full_url, headers={"User-Agent": f"DeployShip/{APP_VERSION}"})
                with urllib.request.urlopen(req, timeout=30) as resp:
                    target.write_bytes(resp.read())
                url_map[url_path] = local_rel
                self._log(f"   ✓ {local_name}")
            except Exception as e:
                failed.append((url_path, str(e)))
                self._log(f"   ✗ {url_path} — {e}")

        # 4. Rewrite references
        if url_map:
            replaced_files = 0
            for f in files_to_scan:
                try:
                    content = f.read_text(encoding="utf-8", errors="ignore")
                except Exception:
                    continue
                original = content
                for old, new in url_map.items():
                    content = content.replace(old, new)
                if content != original:
                    f.write_text(content, encoding="utf-8")
                    replaced_files += 1

            # Rewrite .asset.json pointers to local URL
            for af in asset_json_files:
                try:
                    data = json.loads(af.read_text(encoding="utf-8"))
                    if isinstance(data, dict) and data.get("url") in url_map:
                        data["url"] = url_map[data["url"]]
                        af.write_text(json.dumps(data, indent=2), encoding="utf-8")
                        replaced_files += 1
                except Exception:
                    pass

            self._log(f"   ✓ Rewrote references in {replaced_files} file(s)")

        if failed:
            self._log(f"   \u26a0 {len(failed)} asset(s) could not be downloaded \u2014 site may show broken images.")
            missing = "\n".join(f"  \u2022 {url}" for url, _ in failed)
            fix_prompt = (
                "Move all images stored in Lovable cloud storage (URLs starting with /__l5e/ or /lovable-uploads/) into the project as local files under src/assets/. Replace every /__l5e/ and /lovable-uploads/ URL in the code with proper Vite import statements. Remove all .asset.json pointer files. Make sure no /__l5e/ or /lovable-uploads/ URLs remain anywhere in the codebase."
            )
            self.after(0, lambda: self._show_missing_assets_popup(missing, fix_prompt))


    def _show_missing_assets_popup(self, missing: str, fix_prompt: str):
        dlg = tk.Toplevel(self)
        dlg.title("⚠ Missing Images Detected")
        dlg.geometry("620x520")
        dlg.resizable(False, False)
        dlg.configure(bg="#0f172a")
        dlg.grab_set()
        dlg.focus_set()

        tk.Label(
            dlg, text="⚠  Images missing from your project",
            font=("Segoe UI", 13, "bold"), fg="#fbbf24", bg="#0f172a"
        ).pack(pady=(18, 4))

        tk.Label(
            dlg,
            text="Some images are stored in Lovable's cloud — not in your project files.\n"
                 "They will be missing on your own server.",
            font=("Segoe UI", 9), fg="#94a3b8", bg="#0f172a", justify="center"
        ).pack(pady=(0, 10))

        # Missing files list
        list_frame = tk.Frame(dlg, bg="#1e293b", padx=12, pady=8)
        list_frame.pack(fill="x", padx=20)
        tk.Label(list_frame, text=missing, font=("Consolas", 8), fg="#fca5a5",
                 bg="#1e293b", justify="left").pack(anchor="w")

        tk.Label(
            dlg, text="Fix it — copy this prompt into Lovable (or your AI builder):",
            font=("Segoe UI", 9, "bold"), fg="#e2e8f0", bg="#0f172a"
        ).pack(pady=(14, 4))

        prompt_box = tk.Text(
            dlg, height=6, bg="#1e293b", fg="#4ade80",
            font=("Consolas", 8), relief="flat", wrap="word"
        )
        prompt_box.insert("1.0", fix_prompt)
        prompt_box.config(state="disabled")
        prompt_box.pack(fill="x", padx=20)

        def _copy():
            self.clipboard_clear()
            self.clipboard_append(fix_prompt)
            copy_btn.config(text="✓  Copied!")
            self.after(2000, lambda: copy_btn.config(text="📋  Copy prompt"))

        btn_frame = tk.Frame(dlg, bg="#0f172a")
        btn_frame.pack(pady=14)

        copy_btn = tk.Button(
            btn_frame, text="📋  Copy prompt", font=("Segoe UI", 10, "bold"),
            fg="white", bg="#0284c7", activebackground="#0369a1",
            relief="flat", padx=14, pady=8, cursor="hand2", command=_copy
        )
        copy_btn.pack(side="left", padx=8)

        tk.Button(
            btn_frame, text="Continue anyway",
            font=("Segoe UI", 9), fg="#94a3b8", bg="#1e293b",
            activebackground="#334155", relief="flat",
            padx=14, pady=8, cursor="hand2", command=dlg.destroy
        ).pack(side="left", padx=8)

    def _guess_lovable_base_url(self, project_dir: Path) -> str:
        """Try to derive the Lovable preview URL from project metadata."""
        # 1. .lovable/project.json
        lp = project_dir / ".lovable" / "project.json"
        if lp.exists():
            try:
                data = json.loads(lp.read_text(encoding="utf-8"))
                pid = data.get("id") or data.get("projectId")
                if pid:
                    return f"https://preview--{pid}.lovable.app"
            except Exception:
                pass
        # 2. package.json homepage
        pkg = project_dir / "package.json"
        if pkg.exists():
            try:
                data = json.loads(pkg.read_text(encoding="utf-8"))
                hp = data.get("homepage", "")
                if hp.startswith("http"):
                    return hp.rstrip("/")
            except Exception:
                pass
        return ""

    @staticmethod
    def _safe_filename(url_path: str) -> str:
        """Turn /__l5e/assets-v1/<uuid>/<name>.jpg → <uuid>_<name>.jpg"""
        parts = [p for p in url_path.split("/") if p]
        if len(parts) >= 2:
            uuid_part = parts[-2][:8]
            name = parts[-1]
            return f"{uuid_part}_{name}"
        return parts[-1] if parts else "asset.bin"

    # ── Branding Cleanup (NEW v2.0) ─────────────────────────────────────────────

    def _strip_branding(self, project_dir: Path):
        files_to_scan = self._collect_text_files(project_dir)
        stats = {k: 0 for k in BRANDING_PATTERNS}
        edited = 0

        for f in files_to_scan:
            try:
                content = f.read_text(encoding="utf-8", errors="ignore")
            except Exception:
                continue
            original = content
            for name, pat in BRANDING_PATTERNS.items():
                new_content, n = pat.subn("", content)
                if n:
                    stats[name] += n
                    content = new_content
            if content != original:
                f.write_text(content, encoding="utf-8")
                edited += 1

        # Special handling: remove lovable-tagger from package.json devDependencies
        pkg = project_dir / "package.json"
        if pkg.exists():
            try:
                data = json.loads(pkg.read_text(encoding="utf-8"))
                changed = False
                for key in ("dependencies", "devDependencies"):
                    if key in data and "lovable-tagger" in data[key]:
                        del data[key]["lovable-tagger"]
                        changed = True
                        stats["lovable_tagger_imp"] += 1
                if changed:
                    pkg.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
            except Exception:
                pass

        # Favicon: if it points to Lovable, replace with a tiny transparent placeholder
        self._neutralize_favicon(project_dir)

        for name, n in stats.items():
            if n:
                self._log(f"   ✓ Removed {n}× {name}")
        self._log(f"   Edited {edited} file(s)")

    def _neutralize_favicon(self, project_dir: Path):
        idx = project_dir / "index.html"
        if not idx.exists():
            return
        try:
            html = idx.read_text(encoding="utf-8")
        except Exception:
            return
        # If favicon points to lovable infra, replace href with a local placeholder
        new_html = re.sub(
            r'(<link[^>]*\brel=["\'](?:icon|shortcut icon)["\'][^>]*\bhref=["\'])[^"\']*(?:lovable|gpteng|__l5e)[^"\']*(["\'][^>]*>)',
            r'\1/favicon.ico\2',
            html, flags=re.I
        )
        if new_html != html:
            idx.write_text(new_html, encoding="utf-8")
            # write a 1x1 transparent ICO placeholder
            placeholder = (project_dir / "public" / "favicon.ico")
            placeholder.parent.mkdir(exist_ok=True)
            if not placeholder.exists():
                # 16x16 transparent ICO bytes
                ico = (b"\x00\x00\x01\x00\x01\x00\x10\x10\x00\x00\x01\x00\x20\x00\x68\x04"
                       b"\x00\x00\x16\x00\x00\x00\x28\x00\x00\x00\x10\x00\x00\x00\x20\x00"
                       b"\x00\x00\x01\x00\x20\x00\x00\x00\x00\x00\x40\x04\x00\x00\x00\x00"
                       b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00"
                       + b"\x00" * (16 * 16 * 4)
                       + b"\x00" * (16 * 4))
                try:
                    placeholder.write_bytes(ico)
                except Exception:
                    pass
            self._log("   ✓ Neutralized Lovable favicon (replaced with placeholder)")

    def _post_build_sweep(self, project_dir: Path):
        """Strip data-lov-* and platform refs from built output too."""
        dist = project_dir / "dist"
        if not dist.exists():
            return
        edited = 0
        for f in dist.rglob("*"):
            if not f.is_file() or f.suffix.lower() not in {".html", ".js", ".mjs", ".css"}:
                continue
            try:
                content = f.read_text(encoding="utf-8", errors="ignore")
            except Exception:
                continue
            original = content
            content = BRANDING_PATTERNS["lovable_data_attr"].sub("", content)
            content = BRANDING_PATTERNS["lovable_script"].sub("", content)
            content = BRANDING_PATTERNS["generator_meta"].sub("", content)
            if content != original:
                try:
                    f.write_text(content, encoding="utf-8")
                    edited += 1
                except Exception:
                    pass
        self._log(f"   ✓ Swept {edited} built file(s)")

    @staticmethod
    def _collect_text_files(project_dir: Path):
        skip_dirs = {"node_modules", "dist", ".git", ".lovable", "build", "_deployship"}
        out = []
        for f in project_dir.rglob("*"):
            if not f.is_file():
                continue
            if any(part in skip_dirs or part.startswith("_deployship") for part in f.parts):
                continue
            if f.suffix.lower() in TEXT_EXTS or f.name in ("index.html", ".htaccess"):
                out.append(f)
        return out

    # ── Vite Config Patch ───────────────────────────────────────────────────────

    def _patch_vite_config_base(self, project_dir: Path):
        for name in ["vite.config.ts", "vite.config.js"]:
            cfg = project_dir / name
            if not cfg.exists():
                continue
            content = cfg.read_text(encoding="utf-8")
            if "base:" in content:
                self._log("   ℹ base: already set")
                return
            patched = re.sub(
                r"(defineConfig\(\s*\{)",
                r"\1\n  base: './',",
                content, count=1
            )
            if patched == content:
                patched = re.sub(
                    r"(defineConfig\([^)]*\)\s*=>\s*\(\s*\{)",
                    r"\1\n  base: './',",
                    content, count=1
                )
            if patched != content:
                cfg.write_text(patched, encoding="utf-8")
                self._log("   ✓ base: './' added to vite.config.ts")
            else:
                self._log("   ⚠ Could not auto-patch vite.config.ts – add base: './' manually")
            return

    # ── SSR Render ──────────────────────────────────────────────────────────────

    # FIX #6: handle nested routes (posts.$id.tsx → /posts/:id is dynamic, skip;
    # but posts.about.tsx → /posts/about should be detected).
    def _detect_routes(self, project_dir: Path) -> list:
        routes = ["/"]
        route_tree = project_dir / "src" / "routeTree.gen.ts"
        if route_tree.exists():
            content = route_tree.read_text(encoding="utf-8")
            found = re.findall(r"path:\s*['\"]([^'\"]+)['\"]", content)
            for p in found:
                if p and p != "/" and "$" not in p and not p.startswith("_"):
                    route = p if p.startswith("/") else "/" + p
                    if route not in routes:
                        routes.append(route)

        # Always also scan the routes/ folder recursively, merging.
        routes_dir = project_dir / "src" / "routes"
        if routes_dir.exists():
            for f in routes_dir.rglob("*.tsx"):
                rel = f.relative_to(routes_dir).with_suffix("")
                parts = list(rel.parts)
                # Skip layouts, root, dynamic, api routes
                if any(p.startswith("_") or p == "__root" or "$" in p for p in parts):
                    continue
                if parts and parts[0] == "api":
                    continue
                # Convert dot-notation (posts.about) into nested path /posts/about
                segments = []
                for p in parts:
                    segments.extend(p.split("."))
                if segments and segments[-1] == "index":
                    segments = segments[:-1]
                route = "/" + "/".join(segments) if segments else "/"
                if route and route not in routes:
                    routes.append(route)
        return routes

    def _render_via_ssr(self, project_dir: Path):
        server_js = project_dir / "dist" / "server" / "server.js"
        if not server_js.exists():
            self._log("   ⚠ SSR server not found – falling back to index.html")
            self._generate_index_html(project_dir)
            return

        routes = self._detect_routes(project_dir)
        self._log(f"   Routes: {', '.join(routes)}")

        routes_js = json.dumps(routes)
        render_script = project_dir / "_render_ssr.mjs"

        # FIX #4: complete SSR handler signature.
        render_script.write_text(
            "import mod from './dist/server/server.js';\n"
            "import { writeFileSync, mkdirSync } from 'fs';\n"
            "import { join, dirname } from 'path';\n\n"
            "const handler = mod?.default?.fetch || mod?.fetch || mod?.default;\n"
            "if (!handler) { console.error('NO_HANDLER'); process.exit(1); }\n\n"
            f"const routes = {routes_js};\n"
            "const env = process.env;\n"
            "const ctx = { waitUntil: () => {}, passThroughOnException: () => {} };\n\n"
            "for (const route of routes) {\n"
            "  try {\n"
            "    const req = new Request('http://localhost:3000' + route, "
            "{ headers: { accept: 'text/html' } });\n"
            "    const res = await handler(req, env, ctx);\n"
            "    if (!res || res.status >= 400) { console.log('SKIP:' + route + ' status=' + (res && res.status)); continue; }\n"
            "    const html = await res.text();\n"
            "    const out = route === '/' ? 'dist/client/index.html'\n"
            "      : join('dist/client', route.replace(/^\\//,''), 'index.html');\n"
            "    mkdirSync(dirname(out), { recursive: true });\n"
            "    writeFileSync(out, html);\n"
            "    console.log('OK:' + route);\n"
            "  } catch(e) { console.log('ERR:' + route + ':' + e.message); }\n"
            "}\n",
            encoding="utf-8"
        )
        try:
            self._run_cmd(["node", "_render_ssr.mjs"], project_dir)
            self._log("   ✓ Pages rendered")
        except RuntimeError:
            self._log("   ⚠ SSR render failed – falling back to index.html")
            self._generate_index_html(project_dir)
        finally:
            render_script.unlink(missing_ok=True)

    def _generate_index_html(self, project_dir: Path):
        dist_client = project_dir / "dist" / "client"
        manifest_path = dist_client / ".vite" / "manifest.json"
        css_file = None
        js_entry = None
        if manifest_path.exists():
            manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
            for chunk in manifest.values():
                if chunk.get("isEntry") and chunk.get("file"):
                    js_entry = chunk["file"]
                    if chunk.get("css"):
                        css_file = chunk["css"][0]
                    break
        assets_dir = dist_client / "assets"
        if assets_dir.exists():
            files = list(assets_dir.iterdir())
            if not css_file:
                for f in files:
                    if f.suffix == ".css":
                        css_file = "assets/" + f.name
                        break
            if not js_entry:
                index_js = sorted(
                    [f for f in files if f.name.startswith("index-") and f.suffix == ".js"],
                    key=lambda f: f.stat().st_size
                )
                if index_js:
                    js_entry = "assets/" + index_js[0].name
        if not js_entry:
            raise RuntimeError("No JS entry point found in dist/client/assets/")
        css_tag = f'  <link rel="stylesheet" href="/{css_file}">' if css_file else ""
        html = "\n".join([
            "<!DOCTYPE html>",
            '<html lang="en">',
            "<head>",
            '  <meta charset="utf-8">',
            '  <meta name="viewport" content="width=device-width, initial-scale=1">',
            '  <link rel="icon" type="image/x-icon" href="/favicon.ico">',
            css_tag,
            "</head>",
            "<body>",
            '  <script>window.__TSR_DEHYDRATED__={"state":{"dehydratedMatches":[]}}</script>',
            f'  <script type="module" src="/{js_entry}"></script>',
            "</body>",
            "</html>",
        ])
        (dist_client / "index.html").write_text(html, encoding="utf-8")
        self._log("   ✓ index.html generated")

    # ── Utilities ───────────────────────────────────────────────────────────────

    def _find_project_dir(self, base: Path) -> Path:
        if (base / "package.json").exists():
            return base
        for sub in base.iterdir():
            if sub.is_dir() and (sub / "package.json").exists():
                return sub
        raise FileNotFoundError("No project folder with package.json found.")

    def _find_output(self, project_dir: Path) -> Path:
        for c in [project_dir / "dist" / "client", project_dir / "dist"]:
            if c.exists():
                self._log(f"   Output: {c.relative_to(project_dir)}/")
                return c
        raise FileNotFoundError("No output folder found. Check the log for build errors.")

    def _run_cmd(self, cmd, cwd, env=None):
        if sys.platform == "win32" and cmd[0] in ("npm", "npx"):
            cmd[0] = cmd[0] + ".cmd"
        process = subprocess.Popen(
            cmd, cwd=str(cwd),
            stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
            text=True, encoding="utf-8", errors="replace",
            env=env
        )
        for line in process.stdout:
            self._log("   " + line.rstrip())
        process.wait()
        if process.returncode != 0:
            raise RuntimeError(f"Command failed: {' '.join(cmd)}")

    def _log(self, text: str):
        self._log_buffer.append(text)
        def _write():
            self.log_widget.config(state="normal")
            stripped = text.strip()
            tag = "error" if stripped.startswith("✗") or stripped.startswith("❌") \
                else "warn" if stripped.startswith("⚠") \
                else ""
            self.log_widget.insert("end", text + "\n", tag)
            self.log_widget.see("end")
            self.log_widget.config(state="disabled")
        self.after(0, _write)


# ── Entry Point ─────────────────────────────────────────────────────────────────

if __name__ == "__main__":
    app = App()
    app.mainloop()
