"""
DeployShip SEO (Beta)
=====================
Deploy TanStack Start & Vite+React projects to any web host —
now with an optional built-in SEO check & fix step.

Drop your GitHub ZIP → optionally run an SEO check → 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 THIS BETA (SEO)
-----------------------
• Optional "Run SEO check now?" step before every build.
• Scans index.html / public/ for: <title>, meta description,
  Open Graph tags, Twitter Card tags, canonical URL, favicon,
  site.webmanifest, sitemap.xml, robots.txt.
• Shows a checklist of found issues — user can deselect any item.
• Step-by-step follow-up dialogs: title & description, domain,
  optional logo/photo upload.
• Generates favicons + app icons + social preview image (Pillow),
  falls back to a generic placeholder (initials) if no image given.
• Generates site.webmanifest, sitemap.xml (from detected routes)
  and robots.txt.

Carried over from v2.0.1
-------------------------
• Asset-Localizer, Branding-Cleanup, 12 bugfixes (see deployship_v2.0.py).

Requirements:
  - Python 3.8+  (https://python.org)
  - Node.js 18+  (https://nodejs.org)
  - Pillow (optional, for SEO image generation): pip install pillow

Build as .exe:
  pip install pyinstaller pillow
  pyinstaller --onefile --windowed --name deployship_seo --add-data "logo.png;." deployship_seo.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 html import escape as html_escape
from tkinter import ttk, filedialog, messagebox
from pathlib import Path

try:
    from PIL import Image, ImageDraw, ImageFont
    _PIL_OK = True
except ImportError:
    _PIL_OK = False

# ── 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.1.0"
APP_NAME       = "DeployShip SEO (Beta)"
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"
THEME_COLOR    = "#0284c7"

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
        self._run_seo_check = False

        # 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"
                f"{APP_NAME} 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"{APP_NAME} requires Node.js 18 or newer.\n\n"
                f"Update: https://nodejs.org"
            ))
        else:
            self._log(f"ℹ Node.js {_node_version} detected (OK).")

        if not _PIL_OK:
            self._log("ℹ Pillow not installed — SEO image generation (favicons, app icons, "
                      "social preview) will be skipped. Install with: pip install pillow")

    # ── 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 — with optional SEO check",
            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(f"Terms of Use – {APP_NAME}")
        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 = (
            f"{APP_NAME} 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"
            f"{APP_NAME} 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")
        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=f"✓  I understand – Continue to {APP_NAME}",
            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

        # NEW: SEO check prompt (Yes/No), asked once per build, in main thread.
        self._run_seo_check = messagebox.askyesno(
            f"{APP_NAME} – SEO Check",
            "Run SEO check now?\n\n"
            f"{APP_NAME} can scan your project for a missing page title, "
            "meta description, Open Graph tags, favicon, web app manifest, "
            "sitemap.xml and robots.txt — and offer to generate/fix them "
            "before building.\n\n"
            "You can always do this on a later build instead."
        )

        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: {APP_NAME} 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:
            self._pending_head_fixes = None
            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: SEO Check (optional, asked before build started)
            if self._run_seo_check:
                self._log("\n── Step 2b: SEO Check ──")
                # Pause the progress bar while waiting for user input in SEO dialogs —
                # it should only animate while the app is actually working.
                self.after(0, lambda: self.progress.stop())
                try:
                    routes = self._detect_routes(project_dir)
                    self._run_on_main_and_wait(lambda: self._seo_flow(project_dir, is_tanstack, routes))
                except Exception as e:
                    self._log(f"   ⚠ SEO check skipped due to an error: {e}")
                finally:
                    self.after(0, lambda: self.progress.start(10))
            else:
                self._log("\n── Step 2b: Skipping SEO check (not selected) ──")

            # 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)

            # 7b. Apply deferred SEO <head> fixes to the built page(s)
            if self._pending_head_fixes:
                self._log("\n── Step 7b: Applying SEO <head> tags to built page(s) ──")
                self._apply_seo_head_to_dist(project_dir, self._pending_head_fixes)

            # 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 {APP_NAME}: {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"{APP_NAME} 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"
                f"{APP_NAME} 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)

    # ══════════════════════════════════════════════════════════════════════════
    # ── NEW: SEO Check & Fix (Phase 1) ──────────────────────────────────────────
    # ══════════════════════════════════════════════════════════════════════════

    def _run_on_main_and_wait(self, func):
        """Run `func` on the Tk main thread and block the calling (build) thread
        until it's done. Required because all dialogs must run on the main thread,
        while the build itself runs on a background thread."""
        done = threading.Event()
        box = {}

        def runner():
            try:
                box["result"] = func()
            except Exception as e:
                box["error"] = e
            finally:
                done.set()

        self.after(0, runner)
        done.wait()
        if "error" in box:
            raise box["error"]
        return box.get("result")

    def _seo_flow(self, project_dir: Path, is_tanstack: bool, routes: list):
        """Main SEO check entry point. Runs on the main thread."""
        issues = self._seo_scan(project_dir)
        if not issues:
            self._log("   ✓ No SEO issues found — looks good!")
            return

        self._log(f"   Found {len(issues)} possible SEO improvement(s)")
        selected = self._show_seo_checklist(issues)
        if not selected:
            self._log("   ℹ SEO fixes skipped by user")
            return

        project_name = self._guess_title(project_dir)

        title = desc = None
        if "meta_basic" in selected or "og_tags" in selected or "twitter_tags" in selected:
            suggested_title = project_name
            title, desc = self._ask_meta_text(suggested_title)

        domain = ""
        if any(k in selected for k in ("og_tags", "twitter_tags", "canonical", "sitemap", "robots")):
            domain = self._ask_domain()

        image_path = None
        if any(k in selected for k in ("favicon", "og_tags", "twitter_tags", "webmanifest")):
            image_path = self._ask_image_upload("favicon, social preview image and app icons")

        self._pending_head_fixes = self._apply_seo_fixes(
            project_dir, selected, title, desc, domain, image_path, routes, project_name
        )
        self._log("   ✓ SEO fixes applied")

    # ── Scan ─────────────────────────────────────────────────────────────────

    def _seo_scan(self, project_dir: Path) -> list:
        idx = project_dir / "index.html"
        html = ""
        head_checkable = idx.exists()
        if head_checkable:
            try:
                html = idx.read_text(encoding="utf-8", errors="ignore")
            except Exception:
                head_checkable = False

        issues = []

        if not head_checkable:
            # No index.html yet (e.g. TanStack Start projects generate it during
            # the build). We can't inspect the existing <head> yet, so we assume
            # title/meta/OG/Twitter/canonical/favicon-links are all missing and
            # offer to add them — they'll be written into dist/client/**/index.html
            # after the build (see _apply_seo_head_to_dist).
            self._log("   ℹ index.html not found yet (generated at build) — assuming <head> tags are missing, will apply after build")

        title_m = re.search(r"<title>(.*?)</title>", html, re.I | re.S)
        has_title = bool(title_m and title_m.group(1).strip())
        has_desc = bool(re.search(
            r'<meta[^>]+name=["\']description["\'][^>]+content=["\']([^"\']{10,})["\']', html, re.I
        ))
        if not (has_title and has_desc):
            issues.append({"key": "meta_basic", "label": "Add/improve page <title> and meta description"})

        has_og = all(re.search(rf'property=["\']og:{p}["\']', html, re.I) for p in ("title", "description", "image"))
        if not has_og:
            issues.append({"key": "og_tags", "label": "Add Open Graph tags (social preview for WhatsApp, LinkedIn, Facebook)"})

        if not re.search(r'name=["\']twitter:card["\']', html, re.I):
            issues.append({"key": "twitter_tags", "label": "Add Twitter/X Card meta tags"})

        if not re.search(r'<link[^>]+rel=["\']canonical["\']', html, re.I):
            issues.append({"key": "canonical", "label": "Add canonical URL (requires your domain)"})

        if not self._has_real_favicon(project_dir, html):
            issues.append({"key": "favicon", "label": "Generate favicon & app icons (16/32/180/192/512px)"})

        manifest_path = project_dir / "public" / "site.webmanifest"
        has_manifest_link = re.search(r'rel=["\']manifest["\']', html, re.I)
        if not (manifest_path.exists() and has_manifest_link):
            issues.append({"key": "webmanifest", "label": "Add web app manifest (site.webmanifest)"})

        if not (project_dir / "public" / "sitemap.xml").exists():
            issues.append({"key": "sitemap", "label": "Generate sitemap.xml from detected routes"})

        if not (project_dir / "public" / "robots.txt").exists():
            issues.append({"key": "robots", "label": "Generate robots.txt"})

        return issues

    def _has_real_favicon(self, project_dir: Path, html: str) -> bool:
        if not re.search(r'<link[^>]+rel=["\'](?:icon|shortcut icon)["\']', html, re.I):
            return False
        fav = project_dir / "public" / "favicon.ico"
        if fav.exists() and fav.stat().st_size > 200:
            return True
        for name in ("favicon-32x32.png", "favicon-16x16.png", "apple-touch-icon.png"):
            if (project_dir / "public" / name).exists():
                return True
        return False

    # Generic placeholder names used by Lovable/Vite/TanStack project scaffolds —
    # not useful as an SEO title, so we skip these and fall back to <h1> or folder name.
    _GENERIC_PROJECT_NAMES = {
        "tanstack-start-ts", "tanstack-start", "vite-react-app", "vite-project",
        "vite-tanstack-config", "my-app", "react-app", "app",
    }

    def _guess_title(self, project_dir: Path) -> str:
        # 1. Prefer the page's visible <h1> — usually the real site/brand name.
        idx = project_dir / "index.html"
        if idx.exists():
            try:
                html = idx.read_text(encoding="utf-8", errors="ignore")
                m = re.search(r"<h1[^>]*>(.*?)</h1>", html, re.I | re.S)
                if m:
                    text = re.sub(r"<[^>]+>", "", m.group(1)).strip()
                    normalized = re.sub(r"[\s_]+", "-", text).strip("-").lower()
                    if text and normalized not in self._GENERIC_PROJECT_NAMES:
                        return text
            except Exception:
                pass

        # 2. package.json "name", unless it's a generic scaffold default.
        pkg = project_dir / "package.json"
        if pkg.exists():
            try:
                data = json.loads(pkg.read_text(encoding="utf-8"))
                name = data.get("name", "")
                if name and name.lower() not in self._GENERIC_PROJECT_NAMES:
                    return re.sub(r"[-_]+", " ", name).strip().title()
            except Exception:
                pass

        # 3. Folder name as last resort.
        return project_dir.name

    # ── Dialogs ──────────────────────────────────────────────────────────────

    def _show_seo_checklist(self, issues: list):
        """Show checklist of found issues. Returns list of selected keys (possibly empty)."""
        result = {"selected": []}
        dlg = tk.Toplevel(self)
        dlg.title(f"{APP_NAME} – Issues Found")
        dlg.resizable(False, False)
        dlg.configure(bg="#0f172a")
        dlg.grab_set()
        dlg.focus_set()

        tk.Label(
            dlg, text="🔍  SEO Check Results",
            font=("Segoe UI", 13, "bold"), fg="#38bdf8", bg="#0f172a"
        ).pack(pady=(18, 4))

        tk.Label(
            dlg,
            text=f"Select the items you want {APP_NAME} to generate/fix automatically.\n"
                 "Unchecked items will be left as-is.",
            font=("Segoe UI", 9), fg="#94a3b8", bg="#0f172a", justify="center"
        ).pack(pady=(0, 12), padx=20)

        list_frame = tk.Frame(dlg, bg="#0f172a")
        list_frame.pack(fill="x", padx=24)

        check_vars = {}
        for issue in issues:
            v = tk.BooleanVar(value=True)
            check_vars[issue["key"]] = v
            tk.Checkbutton(
                list_frame, text=issue["label"], variable=v,
                font=("Segoe UI", 9), fg="#cbd5e1", bg="#0f172a",
                activebackground="#0f172a", activeforeground="white",
                selectcolor="#1e293b", anchor="w", wraplength=460, justify="left"
            ).pack(fill="x", anchor="w", pady=2)

        def _apply():
            result["selected"] = [k for k, v in check_vars.items() if v.get()]
            dlg.destroy()

        def _skip_all():
            result["selected"] = []
            dlg.destroy()

        btns = tk.Frame(dlg, bg="#0f172a")
        btns.pack(pady=16)
        tk.Button(
            btns, text="✓  Apply selected fixes", font=("Segoe UI", 10, "bold"),
            fg="white", bg="#0284c7", activebackground="#0369a1",
            relief="flat", padx=16, pady=8, cursor="hand2", command=_apply
        ).pack(side="left", padx=6)
        tk.Button(
            btns, text="Skip SEO fixes", font=("Segoe UI", 9),
            fg="#94a3b8", bg="#1e293b", activebackground="#334155",
            relief="flat", padx=16, pady=8, cursor="hand2", command=_skip_all
        ).pack(side="left", padx=6)

        dlg.update_idletasks()
        dlg.wait_window()
        return result["selected"]

    def _ask_meta_text(self, suggested_title: str):
        """Returns (title, desc). Title falls back to the suggested project name
        if left empty; description is returned as "" if left empty (in which case
        the existing description, if any, is kept untouched)."""
        result = {"title": "", "desc": ""}
        dlg = tk.Toplevel(self)
        dlg.title(f"{APP_NAME} – Title & Description")
        dlg.resizable(False, False)
        dlg.configure(bg="#0f172a")
        dlg.grab_set()
        dlg.focus_set()

        tk.Label(
            dlg, text="📝  Page Title & Description",
            font=("Segoe UI", 12, "bold"), fg="#38bdf8", bg="#0f172a"
        ).pack(pady=(16, 8))

        tk.Label(
            dlg,
            text="These appear as the headline and snippet in Google search\n"
                 "results, browser tabs and social media link previews.",
            font=("Segoe UI", 9), fg="#94a3b8", bg="#0f172a", justify="left"
        ).pack(anchor="w", padx=20, pady=(0, 10))

        tk.Label(
            dlg, text="Title (about 50–60 characters recommended):",
            font=("Segoe UI", 9), fg="#cbd5e1", bg="#0f172a"
        ).pack(anchor="w", padx=20)
        title_var = tk.StringVar(value=suggested_title)
        tk.Entry(dlg, textvariable=title_var, font=("Segoe UI", 10), width=56).pack(padx=20, pady=(2, 2))
        tk.Label(
            dlg, text="Leave empty to keep the suggested name above.",
            font=("Segoe UI", 8, "italic"), fg="#64748b", bg="#0f172a"
        ).pack(anchor="w", padx=20, pady=(0, 10))

        tk.Label(
            dlg, text="Description (about 120–160 characters recommended):",
            font=("Segoe UI", 9), fg="#cbd5e1", bg="#0f172a"
        ).pack(anchor="w", padx=20)
        tk.Label(
            dlg,
            text='Briefly describe what this website offers and for whom.\n'
                 'Example: "Professional web design and SEO services for small\n'
                 'businesses in Berlin."',
            font=("Segoe UI", 8, "italic"), fg="#64748b", bg="#0f172a", justify="left"
        ).pack(anchor="w", padx=20)
        desc_text = tk.Text(dlg, font=("Segoe UI", 10), width=56, height=4, wrap="word")
        desc_text.pack(padx=20, pady=(4, 2))
        tk.Label(
            dlg, text="Leave empty to keep the existing description (if any).",
            font=("Segoe UI", 8, "italic"), fg="#64748b", bg="#0f172a"
        ).pack(anchor="w", padx=20, pady=(0, 4))

        def _ok():
            result["title"] = title_var.get().strip() or suggested_title
            result["desc"] = desc_text.get("1.0", "end").strip()
            dlg.destroy()

        def _skip():
            # Skip entirely: keep existing title/description untouched.
            result["title"] = ""
            result["desc"] = ""
            dlg.destroy()

        btns = tk.Frame(dlg, bg="#0f172a")
        btns.pack(pady=14)
        tk.Button(
            btns, text="✓  Use this", font=("Segoe UI", 10, "bold"),
            fg="white", bg="#0284c7", activebackground="#0369a1",
            relief="flat", padx=16, pady=8, cursor="hand2", command=_ok
        ).pack(side="left", padx=6)
        tk.Button(
            btns, text="Skip", font=("Segoe UI", 9),
            fg="#94a3b8", bg="#1e293b", activebackground="#334155",
            relief="flat", padx=16, pady=8, cursor="hand2", command=_skip
        ).pack(side="left", padx=6)

        dlg.update_idletasks()
        dlg.wait_window()
        return result["title"], result["desc"]

    def _ask_domain(self) -> str:
        """Returns the domain (with https://) or empty string if skipped."""
        result = {"domain": ""}
        dlg = tk.Toplevel(self)
        dlg.title(f"{APP_NAME} – Website Domain")
        dlg.resizable(False, False)
        dlg.configure(bg="#0f172a")
        dlg.grab_set()
        dlg.focus_set()

        tk.Label(
            dlg, text="🌐  Your live domain",
            font=("Segoe UI", 12, "bold"), fg="#38bdf8", bg="#0f172a"
        ).pack(pady=(16, 8))

        tk.Label(
            dlg,
            text="Used for the canonical URL, sitemap.xml and Open Graph/Twitter links.\n"
                 "Example: https://example.com\n"
                 "⚠ Enter the exact address you'll use live (https:// vs http://, www vs non-www,\n"
                 "no trailing slash) — getting this wrong can cause SEO/canonical issues.",
            font=("Segoe UI", 9), fg="#94a3b8", bg="#0f172a", justify="left"
        ).pack(padx=20)

        var = tk.StringVar(value="https://")
        entry = tk.Entry(dlg, textvariable=var, font=("Segoe UI", 10), width=44)
        entry.pack(pady=12)
        entry.icursor("end")

        def _ok():
            result["domain"] = var.get().strip()
            dlg.destroy()

        def _skip():
            dlg.destroy()

        btns = tk.Frame(dlg, bg="#0f172a")
        btns.pack(pady=(0, 14))
        tk.Button(
            btns, text="✓  Continue", font=("Segoe UI", 10, "bold"),
            fg="white", bg="#0284c7", activebackground="#0369a1",
            relief="flat", padx=16, pady=8, cursor="hand2", command=_ok
        ).pack(side="left", padx=6)
        tk.Button(
            btns, text="Skip", font=("Segoe UI", 9),
            fg="#94a3b8", bg="#1e293b", activebackground="#334155",
            relief="flat", padx=16, pady=8, cursor="hand2", command=_skip
        ).pack(side="left", padx=6)

        dlg.update_idletasks()
        dlg.wait_window()

        domain = result["domain"]
        if domain and not domain.lower().startswith(("http://", "https://")):
            domain = "https://" + domain
        return domain.rstrip("/")

    def _ask_image_upload(self, purpose: str):
        """Returns a file path, or None if skipped (generic placeholder will be used)."""
        if not _PIL_OK:
            self._log("   ℹ Pillow not installed — a generic placeholder cannot be generated either; "
                       "favicon/icons/social image will be skipped.")
            return None

        want = messagebox.askyesno(
            f"{APP_NAME} – Logo / Photo",
            f"Do you want to upload a logo or photo for {purpose}?\n\n"
            f"If you click 'No', {APP_NAME} will generate a generic placeholder "
            f"image automatically (based on your project name)."
        )
        if not want:
            return None
        path = filedialog.askopenfilename(
            title=f"Select image for {purpose}",
            filetypes=[("Images", "*.png;*.jpg;*.jpeg;*.webp")]
        )
        return path or None

    # ── Apply fixes ──────────────────────────────────────────────────────────

    def _apply_seo_fixes(self, project_dir: Path, selected: list, title: str, desc: str,
                          domain: str, image_path, routes: list, project_name: str):
        public_dir = project_dir / "public"
        public_dir.mkdir(exist_ok=True)

        has_favicon = "favicon" in selected
        has_manifest = "webmanifest" in selected
        want_images = has_favicon or has_manifest or "og_tags" in selected or "twitter_tags" in selected

        images_ok = False
        if want_images:
            try:
                self._generate_image_assets(public_dir, image_path, project_name)
                images_ok = True
                self._log("   ✓ Generated favicon, app icons & social preview image")
            except Exception as e:
                self._log(f"   ⚠ Image generation skipped ({e})")
                has_favicon = False
                has_manifest = False

        if has_manifest and images_ok:
            self._generate_manifest(project_dir, project_name)
            self._log("   ✓ site.webmanifest created")
        elif has_manifest and not images_ok:
            has_manifest = False

        head_needed = (
            "meta_basic" in selected or "og_tags" in selected or "twitter_tags" in selected
            or "canonical" in selected or has_favicon or has_manifest
        )
        pending_head_fixes = None
        if head_needed:
            head_kwargs = dict(
                title=title or "",
                desc=desc or "",
                domain=domain,
                add_favicon=has_favicon,
                add_manifest=has_manifest,
                add_og=("og_tags" in selected),
                add_twitter=("twitter_tags" in selected),
                add_canonical=("canonical" in selected),
                images_ok=images_ok,
            )
            idx = project_dir / "index.html"
            if idx.exists():
                if self._apply_seo_head(idx, **head_kwargs):
                    self._log("   ✓ index.html <head> updated")
            else:
                # No index.html yet (e.g. TanStack Start — generated during the
                # build). Defer the head injection until after the build, when
                # dist/client/**/index.html exist.
                self._log("   ℹ <head> tags will be added to the built page(s) after the build")
                pending_head_fixes = head_kwargs

        if "sitemap" in selected:
            self._generate_sitemap(project_dir, domain, routes)
            self._log(f"   ✓ sitemap.xml created ({len(routes)} route(s))")

        if "robots" in selected:
            self._generate_robots(project_dir, domain)
            self._log("   ✓ robots.txt created")

        return pending_head_fixes

    def _apply_seo_head(self, idx: Path, title: str, desc: str, domain: str,
                         add_favicon: bool, add_manifest: bool, add_og: bool,
                         add_twitter: bool, add_canonical: bool, images_ok: bool):
        if not idx.exists():
            self._log(f"   ⚠ {idx.name} not found — skipping <head> update")
            return False
        try:
            html = idx.read_text(encoding="utf-8")
        except Exception as e:
            self._log(f"   ⚠ Could not read index.html: {e}")
            return False

        safe_title = html_escape(title or "")
        safe_desc = html_escape(desc or "")

        # Remove tags we're about to (re)write, to avoid duplicates.
        # Only remove the existing tag if we actually have a new value to put
        # in its place — otherwise leave the page's current title/description alone.
        if safe_title:
            html = re.sub(r'<title>.*?</title>', '', html, flags=re.I | re.S)
        if safe_desc:
            html = re.sub(r'<meta[^>]+name=["\']description["\'][^>]*>', '', html, flags=re.I)
        if add_og:
            html = re.sub(r'<meta[^>]+property=["\']og:[^"\']+["\'][^>]*>', '', html, flags=re.I)
        if add_twitter:
            html = re.sub(r'<meta[^>]+name=["\']twitter:[^"\']+["\'][^>]*>', '', html, flags=re.I)
        if add_canonical:
            html = re.sub(r'<link[^>]+rel=["\']canonical["\'][^>]*>', '', html, flags=re.I)
        if add_favicon:
            html = re.sub(r'<link[^>]+rel=["\'](?:icon|shortcut icon|apple-touch-icon)["\'][^>]*>', '', html, flags=re.I)
        if add_manifest:
            html = re.sub(r'<link[^>]+rel=["\']manifest["\'][^>]*>', '', html, flags=re.I)
            html = re.sub(r'<meta[^>]+name=["\']theme-color["\'][^>]*>', '', html, flags=re.I)

        url = domain.rstrip("/") if domain else ""
        og_image = f"{url}/og-image.png" if url else "/og-image.png"

        lines = []
        if safe_title:
            lines.append(f"  <title>{safe_title}</title>")
        if safe_desc:
            lines.append(f'  <meta name="description" content="{safe_desc}">')
        if add_canonical and url:
            lines.append(f'  <link rel="canonical" href="{url}/">')

        if add_og:
            lines.append('  <meta property="og:type" content="website">')
            if safe_title:
                lines.append(f'  <meta property="og:title" content="{safe_title}">')
            if safe_desc:
                lines.append(f'  <meta property="og:description" content="{safe_desc}">')
            if images_ok:
                lines.append(f'  <meta property="og:image" content="{og_image}">')
            if url:
                lines.append(f'  <meta property="og:url" content="{url}/">')

        if add_twitter:
            lines.append('  <meta name="twitter:card" content="summary_large_image">')
            if safe_title:
                lines.append(f'  <meta name="twitter:title" content="{safe_title}">')
            if safe_desc:
                lines.append(f'  <meta name="twitter:description" content="{safe_desc}">')
            if images_ok:
                lines.append(f'  <meta name="twitter:image" content="{og_image}">')

        if add_favicon:
            lines += [
                '  <link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png">',
                '  <link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png">',
                '  <link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png">',
                '  <link rel="icon" href="/favicon.ico">',
            ]

        if add_manifest:
            lines.append('  <link rel="manifest" href="/site.webmanifest">')
            lines.append(f'  <meta name="theme-color" content="{THEME_COLOR}">')

        block = "\n".join(lines)
        if block:
            block += "\n"

        if "</head>" in html:
            html = html.replace("</head>", block + "</head>", 1)
        else:
            html = block + html

        # Tidy up excessive blank lines left behind by removed tags.
        html = re.sub(r'\n{3,}', '\n\n', html)
        idx.write_text(html, encoding="utf-8")
        return True

    def _apply_seo_head_to_dist(self, project_dir: Path, head_kwargs: dict):
        """Apply deferred SEO <head> fixes to every rendered page in dist/client/.
        Used for TanStack Start projects, whose index.html files only exist
        after the build/SSR-render step."""
        dist_client = project_dir / "dist" / "client"
        if not dist_client.exists():
            self._log("   ⚠ dist/client not found — could not apply <head> fixes")
            return

        pages = sorted(dist_client.rglob("index.html"))
        if not pages:
            self._log("   ⚠ No index.html found in dist/client — could not apply <head> fixes")
            return

        updated = 0
        for page in pages:
            if self._apply_seo_head(page, **head_kwargs):
                updated += 1

        if updated:
            self._log(f"   ✓ <head> tags applied to {updated} page(s) in dist/client/")
        else:
            self._log("   ⚠ Could not apply <head> tags to any page")

    # ── Image / icon generation (Pillow) ────────────────────────────────────

    def _generate_image_assets(self, public_dir: Path, image_path, project_name: str):
        if not _PIL_OK:
            raise RuntimeError("Pillow not installed (pip install pillow)")

        if image_path:
            img = Image.open(image_path).convert("RGBA")
            side = min(img.size)
            left = (img.width - side) // 2
            top = (img.height - side) // 2
            img = img.crop((left, top, left + side, top + side))
        else:
            img = self._build_placeholder_image(project_name)

        # Standard favicon / app icon sizes
        sizes = {
            "favicon-16x16.png": 16,
            "favicon-32x32.png": 32,
            "apple-touch-icon.png": 180,
            "android-chrome-192x192.png": 192,
            "android-chrome-512x512.png": 512,
        }
        for name, sz in sizes.items():
            img.resize((sz, sz), Image.LANCZOS).save(public_dir / name)

        # Multi-size favicon.ico
        ico_sizes = [(16, 16), (32, 32), (48, 48)]
        icon_imgs = [img.resize(s, Image.LANCZOS) for s in ico_sizes]
        icon_imgs[0].save(
            public_dir / "favicon.ico", format="ICO",
            sizes=ico_sizes, append_images=icon_imgs[1:]
        )

        # Social preview image (Open Graph / Twitter), 1200x630
        og = Image.new("RGB", (1200, 630), (15, 23, 42))
        thumb = img.resize((480, 480), Image.LANCZOS)
        og.paste(thumb, ((1200 - 480) // 2, (630 - 480) // 2), thumb)
        og.save(public_dir / "og-image.png")

    @staticmethod
    def _build_placeholder_image(project_name: str):
        """Generate a generic square placeholder icon with the project's initials."""
        size = 512
        img = Image.new("RGBA", (size, size), (2, 132, 199, 255))  # #0284c7
        draw = ImageDraw.Draw(img)

        letters = re.findall(r"[A-Za-z0-9]", project_name or "")
        initials = "".join(letters[:2]).upper() or "DS"

        font = None
        for fname in ("arialbd.ttf", "Arial Bold.ttf", "DejaVuSans-Bold.ttf", "Verdana Bold.ttf"):
            try:
                font = ImageFont.truetype(fname, int(size * 0.42))
                break
            except Exception:
                continue
        if font is None:
            font = ImageFont.load_default()

        bbox = draw.textbbox((0, 0), initials, font=font)
        w, h = bbox[2] - bbox[0], bbox[3] - bbox[1]
        draw.text(((size - w) / 2 - bbox[0], (size - h) / 2 - bbox[1]), initials, fill="white", font=font)
        return img

    # ── Manifest / Sitemap / Robots ─────────────────────────────────────────

    def _generate_manifest(self, project_dir: Path, project_name: str):
        manifest = {
            "name": project_name,
            "short_name": project_name[:12],
            "icons": [
                {"src": "/android-chrome-192x192.png", "sizes": "192x192", "type": "image/png"},
                {"src": "/android-chrome-512x512.png", "sizes": "512x512", "type": "image/png"},
            ],
            "theme_color": THEME_COLOR,
            "background_color": "#ffffff",
            "display": "standalone",
            "start_url": "/",
        }
        (project_dir / "public" / "site.webmanifest").write_text(
            json.dumps(manifest, indent=2) + "\n", encoding="utf-8"
        )

    def _generate_sitemap(self, project_dir: Path, domain: str, routes: list):
        domain = domain.rstrip("/") if domain else ""
        today = datetime.now().strftime("%Y-%m-%d")
        lines = [
            '<?xml version="1.0" encoding="UTF-8"?>',
            '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">',
        ]
        for r in routes:
            path = r if r.startswith("/") else "/" + r
            loc = (domain + path) if domain else path
            lines.append("  <url>")
            lines.append(f"    <loc>{html_escape(loc)}</loc>")
            lines.append(f"    <lastmod>{today}</lastmod>")
            lines.append("  </url>")
        lines.append("</urlset>")
        (project_dir / "public" / "sitemap.xml").write_text("\n".join(lines) + "\n", encoding="utf-8")

    def _generate_robots(self, project_dir: Path, domain: str):
        domain = domain.rstrip("/") if domain else ""
        content = "User-agent: *\nAllow: /\n"
        if domain:
            content += f"\nSitemap: {domain}/sitemap.xml\n"
        (project_dir / "public" / "robots.txt").write_text(content, encoding="utf-8")

    # ── Asset Localizer (carried over from 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"   ⚠ {len(failed)} asset(s) could not be downloaded — site may show broken images.")
            missing = "\n".join(f"  • {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 (carried over from 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()
