/**
 * <folk-splat-viewer> — Gaussian splat gallery + 3D viewer web component.
 *
 * Gallery mode: card grid of splats with upload form + AI generation.
 * Viewer mode: full-viewport Three.js + GaussianSplats3D renderer.
 *
 * Three.js and GaussianSplats3D are loaded via CDN importmap (not bundled).
 */

import { LightTourEngine } from "../../../shared/tour-engine";
import type { TourStep } from "../../../shared/tour-engine";
import { startPresenceHeartbeat } from '../../../shared/collab-presence';
import { makeDraggableAll } from "../../../shared/draggable";
import { splatScenesSchema, splatScenesDocId, type SplatScenesDoc } from "../schemas";
import type { DocumentId } from "../../../shared/local-first/document";

const SPLAT_TOUR_STEPS: TourStep[] = [
  { target: '.splat-grid', title: '3D Gallery', message: 'Browse Gaussian splat models — click any card to view in 3D.' },
  { target: '#splat-drop', title: 'Upload / Generate', message: 'Add .ply or .splat files, or generate a 3D model from a single image using AI.' },
  { target: '.splat-gallery__header', title: 'rSplat', message: 'Orbit, zoom, and inspect photorealistic 3D captures.' },
];

interface SplatItem {
  id: string;
  slug: string;
  title: string;
  description?: string;
  file_format: string;
  file_url?: string;
  file_size_bytes: number;
  view_count: number;
  contributor_name?: string;
  thumbnail_url?: string;
  processing_status?: string;
  source_file_count?: number;
  created_at: string;
  demoUrl?: string;
}

export class FolkSplatViewer extends HTMLElement {
  private _mode: "gallery" | "viewer" = "gallery";
  private _splats: SplatItem[] = [];
  private _spaceSlug = "demo";
  private get _basePath(): string {
    if (window.location.hostname.endsWith('.rspace.online')) return '/rsplat';
    return `${this._basePath}`;
  }
  private _tour: LightTourEngine | null = null;
  private _splatUrl = "";
  private _splatTitle = "";
  private _splatDesc = "";
  private _viewer: any = null;
  private _uploadMode: "splat" | "generate" = "generate";
  private _inlineViewer = false;
  private _offlineUnsub: (() => void) | null = null;
  private _stopPresence: (() => void) | null = null;
  private _subscribedDocIds: string[] = [];
  private _generatedUrl = "";
  private _generatedTitle = "";
  private _savedSlug = "";
  private _myHistory: SplatItem[] = [];

  static get observedAttributes() {
    return ["mode", "splat-url", "splat-title", "splat-desc", "space-slug"];
  }

  set splats(val: SplatItem[]) {
    this._splats = val;
    if (this._mode === "gallery") this.renderGallery();
  }

  set spaceSlug(val: string) {
    this._spaceSlug = val;
  }

  connectedCallback() {
    this._mode = (this.getAttribute("mode") as "gallery" | "viewer") || "gallery";
    this._splatUrl = this.getAttribute("splat-url") || "";
    this._splatTitle = this.getAttribute("splat-title") || "";
    this._splatDesc = this.getAttribute("splat-desc") || "";
    this._spaceSlug = this.getAttribute("space-slug") || "demo";

    if (this._mode === "viewer") {
      this.renderViewer();
    } else {
      if (this._spaceSlug === "demo") {
        this.loadDemoData();
      } else {
        this.subscribeOffline();
      }
      this.loadMyHistory();
      this.renderGallery();
    }
    this._stopPresence = startPresenceHeartbeat(() => ({ module: 'rsplat', context: this._splatTitle || '3D Viewer' }));
  }

  private async subscribeOffline() {
    const runtime = (window as any).__rspaceOfflineRuntime;
    if (!runtime?.isInitialized) return;

    try {
      const docId = splatScenesDocId(this._spaceSlug) as DocumentId;
      const doc = await runtime.subscribe(docId, splatScenesSchema);
      this._subscribedDocIds.push(docId);
      this.renderFromDoc(doc);

      this._offlineUnsub = runtime.onChange(docId, (updated: any) => {
        this.renderFromDoc(updated);
      });
    } catch {
      // Runtime unavailable — server-side hydration handles data
    }
  }

  private renderFromDoc(doc: SplatScenesDoc) {
    if (!doc?.items || Object.keys(doc.items).length === 0) return;
    if (this._splats.length > 0) return; // Don't clobber server-hydrated data
    this._splats = Object.values(doc.items).map(s => ({
      id: s.id, slug: s.slug, title: s.title, description: s.description,
      file_format: s.fileFormat,
      file_url: s.filePath ? `${this._basePath}/api/splats/${s.slug}/${s.slug}.${s.fileFormat}` : undefined,
      file_size_bytes: s.fileSizeBytes,
      view_count: s.viewCount, contributor_name: s.contributorName ?? undefined,
      thumbnail_url: (s as any).thumbnailUrl ?? undefined,
      processing_status: s.processingStatus ?? undefined,
      source_file_count: s.sourceFileCount,
      created_at: new Date(s.createdAt).toISOString(),
    }));
    if (this._mode === "gallery") this.renderGallery();
  }

  private loadDemoData() {
    this._splats = [
      { id: "s1", slug: "train", title: "Train", description: "Classic Gaussian splatting demo scene — a model train on a track.", file_format: "splat", file_size_bytes: 6_291_456, view_count: 1842, contributor_name: "MipNeRF 360", processing_status: "ready", created_at: "2025-06-15", demoUrl: "https://huggingface.co/cakewalk/splat-data/resolve/main/train.splat" },
      { id: "s2", slug: "truck", title: "Truck", description: "Photogrammetry capture of a pickup truck — 360° Gaussian splat reconstruction.", file_format: "splat", file_size_bytes: 6_291_456, view_count: 1536, contributor_name: "MipNeRF 360", processing_status: "ready", created_at: "2025-06-15", demoUrl: "https://huggingface.co/cakewalk/splat-data/resolve/main/truck.splat" },
      { id: "s3", slug: "garden", title: "Garden", description: "Outdoor garden scene — dense foliage and complex lighting captured as Gaussian splats.", file_format: "splat", file_size_bytes: 6_291_456, view_count: 1203, contributor_name: "MipNeRF 360", processing_status: "ready", created_at: "2025-06-15", demoUrl: "https://huggingface.co/cakewalk/splat-data/resolve/main/garden.splat" },
    ];
  }

  private async loadMyHistory() {
    const token = this.getAuthToken();
    if (!token || this._spaceSlug === "demo") return;

    try {
      const res = await fetch(`${this._basePath}/api/splats/my-history`, {
        headers: { Authorization: `Bearer ${token}` },
      });
      if (res.ok) {
        const data = await res.json();
        this._myHistory = data.splats || [];
        if (this._mode === "gallery") this.renderGallery();
      }
    } catch { /* non-critical */ }
  }

  disconnectedCallback() {
    this._stopPresence?.();
    this._offlineUnsub?.();
    this._offlineUnsub = null;
    const runtime = (window as any).__rspaceOfflineRuntime;
    if (runtime) {
      for (const id of this._subscribedDocIds) runtime.unsubscribe(id);
    }
    this._subscribedDocIds = [];
    if (this._viewer) {
      try { this._viewer.dispose(); } catch {}
      this._viewer = null;
    }
    if ((this as any)._glbCleanup) {
      try { (this as any)._glbCleanup(); } catch {}
      (this as any)._glbCleanup = null;
    }
  }

  attributeChangedCallback(name: string, _old: string, val: string) {
    if (name === "mode") this._mode = val as "gallery" | "viewer";
    if (name === "splat-url") this._splatUrl = val;
    if (name === "splat-title") this._splatTitle = val;
    if (name === "splat-desc") this._splatDesc = val;
    if (name === "space-slug") this._spaceSlug = val;
  }

  // ── Gallery ──

  private renderCard(s: SplatItem): string {
    const status = s.processing_status || "ready";
    const isReady = status === "ready";
    const isDemo = !!s.demoUrl;
    const tag = isReady ? (isDemo ? "div" : "a") : "div";
    const href = isReady && !isDemo ? ` href="${this._basePath}/${s.slug}"` : "";
    const demoAttr = isDemo ? ` data-demo-url="${esc(s.demoUrl!)}" data-demo-title="${esc(s.title)}" data-demo-desc="${esc(s.description || "")}" role="button" tabindex="0"` : "";
    const statusClass = !isReady ? ` splat-card--${status}` : "";
    const demoClass = isDemo ? " splat-card--demo" : "";

    let overlay = "";
    if (status === "pending") {
      overlay = `<div class="splat-card__overlay"><span class="splat-badge splat-badge--pending">Queued</span></div>`;
    } else if (status === "processing") {
      overlay = `<div class="splat-card__overlay"><div class="splat-card__spinner"></div><span class="splat-badge splat-badge--processing">Generating...</span></div>`;
    } else if (status === "failed") {
      overlay = `<div class="splat-card__overlay"><span class="splat-badge splat-badge--failed">Failed</span></div>`;
    }

    // Thumbnail content: model-viewer for GLB, img for thumbnail_url, emoji fallback
    let previewContent = "";
    if (isReady && s.file_format === "glb" && s.file_url) {
      previewContent = `<model-viewer src="${esc(s.file_url)}" auto-rotate interaction-prompt="none" shadow-intensity="0" environment-image="neutral" loading="lazy" class="splat-card__model-viewer"></model-viewer>`;
    } else if (isReady && s.thumbnail_url) {
      previewContent = `<img src="${esc(s.thumbnail_url)}" alt="${esc(s.title)}" class="splat-card__thumb" loading="lazy">`;
    } else {
      previewContent = `<span>${isReady ? "🔮" : "📸"}</span>`;
    }

    const sourceInfo = !isReady && s.source_file_count
      ? `<span>${s.source_file_count} source file${s.source_file_count > 1 ? "s" : ""}</span>`
      : `<span>${s.view_count} views</span>`;

    return `
    <${tag} class="splat-card${statusClass}${demoClass}" data-collab-id="splat:${s.id}"${href}${demoAttr}>
      <div class="splat-card__preview">
        ${overlay}
        ${previewContent}
      </div>
      <div class="splat-card__body">
        <div class="splat-card__title">${esc(s.title)}</div>
        <div class="splat-card__meta">
          <span class="splat-badge splat-badge--${s.file_format}">${s.file_format}</span>
          ${isReady ? `<span>${formatSize(s.file_size_bytes)}</span>` : ""}
          ${sourceInfo}
        </div>
      </div>
    </${tag}>
  `;
  }

  private renderGallery() {
    const cards = this._splats.map((s) => this.renderCard(s)).join("");

    // My Models section
    const myModelsHtml = this._myHistory.length > 0 ? `
      <div class="splat-my-models">
        <h2 class="splat-my-models__title">My Models</h2>
        <div class="splat-grid">${this._myHistory.map((s) => this.renderCard(s)).join("")}</div>
      </div>
    ` : "";

    const empty = this._splats.length === 0 && this._myHistory.length === 0 ? `
      <div class="splat-empty">
        <div class="splat-empty__icon">🔮</div>
        <h3>No splats yet</h3>
        <p>Upload a .ply, .splat, or .spz file — or photos/video to generate one</p>
      </div>
    ` : "";

    this.innerHTML = `
      <div class="splat-gallery">
        <div class="splat-gallery__header">
          <h1 style="display:flex;align-items:center;gap:0.5rem">rSplat <button style="padding:3px 8px;border-radius:6px;border:1px solid var(--rs-border,#334155);background:var(--rs-bg-surface,#1e293b);color:var(--rs-text-secondary,#94a3b8);cursor:pointer;font-size:0.72rem;font-weight:400" id="btn-tour">Tour</button></h1>
          <p class="splat-gallery__subtitle">Explore and create 3D Gaussian splat scenes</p>
        </div>
        ${myModelsHtml}
        ${empty}
        ${this._splats.length > 0 ? `<h2 class="splat-section-title">Gallery</h2>` : ""}
        <div class="splat-grid">${cards}</div>
        <div class="splat-upload" id="splat-drop">
          <div class="splat-upload__toggle">
            <button class="splat-upload__toggle-btn" data-mode="splat">Upload Splat</button>
            <button class="splat-upload__toggle-btn active" data-mode="generate">Generate from Image</button>
          </div>

          <!-- Splat upload mode -->
          <div class="splat-upload__mode" id="splat-mode-splat" style="display:none">
            <div class="splat-upload__icon">📤</div>
            <p class="splat-upload__text">
              Drag &amp; drop a <strong>.ply</strong>, <strong>.splat</strong>, or <strong>.spz</strong> file here
              <br>or <strong id="splat-browse">browse</strong> to upload
            </p>
            <input type="file" id="splat-file" accept=".ply,.splat,.spz" hidden>
            <div class="splat-upload__form" id="splat-form">
              <input type="text" id="splat-title-input" placeholder="Title (required)" required>
              <textarea id="splat-desc-input" placeholder="Description (optional)" rows="2"></textarea>
              <input type="text" id="splat-tags-input" placeholder="Tags (comma-separated)">
              <button class="splat-upload__btn" id="splat-submit" disabled>Upload Splat</button>
              <div class="splat-upload__status" id="splat-status"></div>
            </div>
          </div>

          <!-- Generate from image mode -->
          <div class="splat-upload__mode" id="splat-mode-generate">
            <div class="splat-upload__icon">✨</div>
            <p class="splat-upload__text">
              Upload a single <strong>image</strong> to generate a 3D model using AI (Hunyuan3D)
              <br>or <strong id="generate-browse">browse</strong> to select an image
            </p>
            <input type="file" id="generate-file" accept=".jpg,.jpeg,.png,.webp" hidden>
            <div class="splat-generate__preview" id="generate-preview"></div>
            <div class="splat-generate__actions" id="generate-actions" style="display:none">
              <button class="splat-upload__btn" id="generate-submit">Generate 3D Model</button>
            </div>
            <div class="splat-generate__progress" id="generate-progress" style="display:none">
              <div class="splat-generate__progress-bar"></div>
              <div class="splat-generate__progress-text" id="generate-progress-text">Generating 3D model...</div>
            </div>
            <div class="splat-upload__status" id="generate-status"></div>
          </div>
        </div>
      </div>
    `;

    this.setupUploadHandlers();
    this.setupGenerateHandlers();
    this.setupToggle();
    this.setupDemoCardHandlers();

    // Tour
    this._tour = new LightTourEngine(this.querySelector('.splat-gallery') as HTMLElement || this, SPLAT_TOUR_STEPS, 'rsplat_tour_done');
    if (!localStorage.getItem('rsplat_tour_done')) {
      setTimeout(() => this._tour?.start(), 800);
    }
    this.querySelector('#btn-tour')?.addEventListener('click', () => this._tour?.start());

    // Make splat cards draggable for calendar reminders
    makeDraggableAll(this, ".splat-card[data-collab-id]", (el) => {
      const title = el.querySelector(".splat-card__title")?.textContent || "";
      const id = el.dataset.collabId?.replace("splat:", "") || "";
      return title ? { title, module: "rsplat", entityId: id, label: "Splat", color: "#818cf8" } : null;
    });
  }

  private setupToggle() {
    const buttons = this.querySelectorAll<HTMLButtonElement>(".splat-upload__toggle-btn");
    const splatMode = this.querySelector("#splat-mode-splat") as HTMLElement;
    const generateMode = this.querySelector("#splat-mode-generate") as HTMLElement;

    buttons.forEach((btn) => {
      btn.addEventListener("click", () => {
        const mode = btn.dataset.mode as "splat" | "generate";
        this._uploadMode = mode;
        buttons.forEach((b) => b.classList.toggle("active", b.dataset.mode === mode));
        splatMode.style.display = mode === "splat" ? "" : "none";
        if (generateMode) generateMode.style.display = mode === "generate" ? "" : "none";
      });
    });
  }

  private setupUploadHandlers() {
    const drop = this.querySelector("#splat-drop") as HTMLElement;
    const fileInput = this.querySelector("#splat-file") as HTMLInputElement;
    const browse = this.querySelector("#splat-browse") as HTMLElement;
    const form = this.querySelector("#splat-form") as HTMLElement;
    const titleInput = this.querySelector("#splat-title-input") as HTMLInputElement;
    const descInput = this.querySelector("#splat-desc-input") as HTMLTextAreaElement;
    const tagsInput = this.querySelector("#splat-tags-input") as HTMLInputElement;
    const submitBtn = this.querySelector("#splat-submit") as HTMLButtonElement;
    const status = this.querySelector("#splat-status") as HTMLElement;

    if (!drop || !fileInput) return;

    let selectedFile: File | null = null;

    browse?.addEventListener("click", () => fileInput.click());

    fileInput.addEventListener("change", () => {
      if (fileInput.files?.[0]) {
        selectedFile = fileInput.files[0];
        form.classList.add("active");
        const name = selectedFile.name.replace(/\.(ply|splat|spz)$/i, "");
        titleInput.value = name.replace(/[-_]/g, " ");
        titleInput.dispatchEvent(new Event("input"));
      }
    });

    drop.addEventListener("dragover", (e) => {
      e.preventDefault();
      drop.classList.add("splat-upload--dragover");
    });

    drop.addEventListener("dragleave", () => {
      drop.classList.remove("splat-upload--dragover");
    });

    drop.addEventListener("drop", (e) => {
      e.preventDefault();
      drop.classList.remove("splat-upload--dragover");
      const file = e.dataTransfer?.files[0];
      if (this._uploadMode === "splat" && file && /\.(ply|splat|spz)$/i.test(file.name)) {
        selectedFile = file;
        form.classList.add("active");
        const name = file.name.replace(/\.(ply|splat|spz)$/i, "");
        titleInput.value = name.replace(/[-_]/g, " ");
        titleInput.dispatchEvent(new Event("input"));
      }
    });

    titleInput?.addEventListener("input", () => {
      submitBtn.disabled = !titleInput.value.trim() || !selectedFile;
    });

    submitBtn?.addEventListener("click", async () => {
      if (!selectedFile || !titleInput.value.trim()) return;

      submitBtn.disabled = true;
      status.textContent = "Uploading...";

      const formData = new FormData();
      formData.append("file", selectedFile);
      formData.append("title", titleInput.value.trim());
      formData.append("description", descInput.value.trim());
      formData.append("tags", tagsInput.value.trim());

      try {
        const token = this.getAuthToken();
        const res = await fetch(`${this._basePath}/api/splats`, {
          method: "POST",
          headers: token ? { Authorization: `Bearer ${token}` } : {},
          body: formData,
        });

        if (res.status === 402) {
          status.textContent = "Payment required for upload (x402)";
          submitBtn.disabled = false;
          return;
        }

        if (res.status === 401) {
          status.textContent = "Sign in with rStack Identity to upload";
          submitBtn.disabled = false;
          return;
        }

        if (!res.ok) {
          const err = await res.json().catch(() => ({ error: "Upload failed" }));
          status.textContent = (err as any).error || "Upload failed";
          submitBtn.disabled = false;
          return;
        }

        const splat = await res.json() as SplatItem;
        status.textContent = "Uploaded!";
        setTimeout(() => {
          window.location.href = `${this._basePath}/${splat.slug}`;
        }, 500);
      } catch (e) {
        status.textContent = "Network error";
        submitBtn.disabled = false;
      }
    });
  }

  private setupDemoCardHandlers() {
    this.querySelectorAll<HTMLElement>(".splat-card--demo").forEach((card) => {
      card.style.cursor = "pointer";
      card.addEventListener("click", (e) => {
        e.preventDefault();
        const url = card.dataset.demoUrl;
        const title = card.dataset.demoTitle || "";
        const desc = card.dataset.demoDesc || "";
        if (!url) return;
        this._mode = "viewer";
        this._splatUrl = url;
        this._splatTitle = title;
        this._splatDesc = desc;
        this._inlineViewer = true;
        this.renderViewer();
      });
    });
  }

  // ── Image staging (replaces canvas-based resizeImage) ──

  private async stageImage(file: File): Promise<string> {
    // Client-side HEIC detection
    const ext = file.name.split(".").pop()?.toLowerCase() || "";
    if (ext === "heic" || ext === "heif" || file.type === "image/heic" || file.type === "image/heif") {
      throw new Error("HEIC files are not supported. Please convert to JPEG or PNG first.");
    }

    const formData = new FormData();
    formData.append("file", file);

    const res = await fetch("/api/image-stage", {
      method: "POST",
      body: formData,
    });

    if (!res.ok) {
      const err = await res.json().catch(() => ({ error: "Image upload failed" }));
      throw new Error((err as any).error || "Image upload failed");
    }

    const data = await res.json() as { url: string };
    return data.url;
  }

  private setupGenerateHandlers() {
    const browse = this.querySelector("#generate-browse") as HTMLElement;
    const fileInput = this.querySelector("#generate-file") as HTMLInputElement;
    const preview = this.querySelector("#generate-preview") as HTMLElement;
    const actions = this.querySelector("#generate-actions") as HTMLElement;
    const submitBtn = this.querySelector("#generate-submit") as HTMLButtonElement;
    const progress = this.querySelector("#generate-progress") as HTMLElement;
    const progressText = this.querySelector("#generate-progress-text") as HTMLElement;
    const status = this.querySelector("#generate-status") as HTMLElement;

    if (!fileInput) return;

    let selectedFile: File | null = null;

    browse?.addEventListener("click", () => fileInput.click());

    // Make the entire dotted drop area open file browser on click
    const generateMode = this.querySelector("#splat-mode-generate") as HTMLElement;
    generateMode?.addEventListener("click", (e) => {
      const target = e.target as HTMLElement;
      // Don't trigger if clicking on buttons, inputs, or the preview image
      if (target.closest("button, input, .splat-generate__preview, .splat-generate__progress")) return;
      fileInput.click();
    });

    fileInput.addEventListener("change", () => {
      if (fileInput.files?.[0]) {
        selectedFile = fileInput.files[0];

        // Client-side HEIC check before preview
        const ext = selectedFile.name.split(".").pop()?.toLowerCase() || "";
        if (ext === "heic" || ext === "heif") {
          status.textContent = "HEIC files are not supported. Please use JPEG or PNG.";
          return;
        }

        const reader = new FileReader();
        reader.onload = () => {
          preview.innerHTML = `<img src="${reader.result}" alt="Preview">`;
          preview.style.display = "block";
          actions.style.display = "flex";
          status.textContent = "";
        };
        reader.readAsDataURL(selectedFile);
      }
    });

    submitBtn?.addEventListener("click", async () => {
      if (!selectedFile) return;

      submitBtn.disabled = true;
      actions.style.display = "none";
      progress.style.display = "block";

      // Elapsed time ticker — resilient to iOS background-tab suspension
      const startTime = Date.now();
      let hiddenTime = 0;
      let hiddenAt = 0;
      const phases = [
        { t: 0, msg: "Staging image..." },
        { t: 3, msg: "Uploading to Hunyuan3D..." },
        { t: 10, msg: "Reconstructing 3D geometry..." },
        { t: 30, msg: "Generating mesh and textures..." },
        { t: 60, msg: "Baking textures..." },
        { t: 120, msg: "Finalizing model..." },
        { t: 180, msg: "Almost there..." },
      ];

      // Realistic progress curve — Hunyuan3D with textures takes 60-180s
      const EXPECTED_SECONDS = 120;
      const progressBar = progress.querySelector(".splat-generate__progress-bar") as HTMLElement;

      const estimatePercent = (elapsed: number): number => {
        if (elapsed <= 0) return 0;
        // Logarithmic curve: fast start, slows toward 95% asymptote
        const ratio = elapsed / EXPECTED_SECONDS;
        return Math.min(95, 100 * (1 - Math.exp(-2.5 * ratio)));
      };

      const onVisChange = () => {
        if (document.hidden) {
          hiddenAt = Date.now();
        } else if (hiddenAt) {
          hiddenTime += Date.now() - hiddenAt;
          hiddenAt = 0;
        }
      };
      document.addEventListener("visibilitychange", onVisChange);

      const ticker = setInterval(() => {
        if (document.hidden) return; // Don't update while hidden
        const elapsed = Math.floor((Date.now() - startTime - hiddenTime) / 1000);
        const phase = [...phases].reverse().find(p => elapsed >= p.t);
        const pct = Math.round(estimatePercent(elapsed));
        if (progressBar) progressBar.style.setProperty("--splat-progress", `${pct}%`);
        if (progressText && phase) {
          progressText.textContent = `${phase.msg} ${pct}% (${elapsed}s)`;
        }
      }, 500);

      try {
        const imageUrl = await this.stageImage(selectedFile!);
        const title = selectedFile.name.replace(/\.[^.]+$/, "");

        status.textContent = "Submitting for 3D generation...";
        const res = await fetch("/api/3d-gen", {
          method: "POST",
          headers: { "Content-Type": "application/json" },
          body: JSON.stringify({ image_url: imageUrl, title }),
        });

        const stopTicker = () => {
          clearInterval(ticker);
          document.removeEventListener("visibilitychange", onVisChange);
        };

        if (res.status === 524 || res.status === 504) {
          stopTicker();
          status.textContent = "Generation timed out — try a simpler image.";
          progress.style.display = "none";
          actions.style.display = "flex";
          submitBtn.disabled = false;
          return;
        }

        if (res.status === 503) {
          stopTicker();
          status.textContent = "AI generation not available — FAL_KEY not configured";
          progress.style.display = "none";
          actions.style.display = "flex";
          submitBtn.disabled = false;
          return;
        }

        if (!res.ok) {
          stopTicker();
          const err = await res.json().catch(() => ({ error: "Generation failed" }));
          status.textContent = (err as any).error || "Generation failed";
          progress.style.display = "none";
          actions.style.display = "flex";
          submitBtn.disabled = false;
          return;
        }

        const { job_id } = await res.json() as { job_id: string };
        // Ticker keeps running — progress bar continues advancing during polling

        // Poll for completion
        let poll404Count = 0;
        const pollInterval = setInterval(async () => {
          try {
            const pollRes = await fetch(`/api/3d-gen/${job_id}`);
            if (!pollRes.ok) {
              if (pollRes.status === 404 && ++poll404Count >= 3) {
                // Job lost — server likely restarted
                stopTicker();
                clearInterval(pollInterval);
                progress.style.display = "none";
                status.textContent = "Job lost — server restarted. Please try again.";
                actions.style.display = "flex";
                submitBtn.disabled = false;
              }
              return;
            }
            poll404Count = 0;
            const job = await pollRes.json() as any;

            if (job.status === "complete") {
              stopTicker();
              clearInterval(pollInterval);
              progress.style.display = "none";

              const sourceImage = job.source_image || "";

              // Show result with save option
              const resultDiv = document.createElement("div");
              resultDiv.className = "splat-generate__result";
              resultDiv.innerHTML = `
                <p style="color:#22c55e;font-weight:600;margin-bottom:12px;">3D model generated successfully!</p>
                <div style="display:flex;gap:8px;flex-wrap:wrap;">
                  <button class="splat-upload__btn" id="gen-view-btn">View 3D Model</button>
                  <button class="splat-upload__btn" id="gen-save-btn" style="background:#22c55e;">Save to Gallery</button>
                  <a href="${job.url}" download class="splat-upload__btn" style="background:#64748b;text-decoration:none;text-align:center;">Download</a>
                </div>
                ${job.email_sent ? '<p style="color:#94a3b8;font-size:13px;margin-top:8px;">Email notification sent!</p>' : ''}
              `;
              status.textContent = "";
              status.after(resultDiv);

              // View button — also store source image for save flow
              resultDiv.querySelector("#gen-view-btn")?.addEventListener("click", () => {
                this._mode = "viewer";
                this._splatUrl = job.url;
                this._splatTitle = title;
                this._splatDesc = "AI-generated 3D model";
                this._generatedUrl = job.url;
                this._generatedTitle = title;
                (this as any)._sourceImageUrl = sourceImage;
                this._inlineViewer = true;
                this.renderViewer();
              });

              // Save to gallery button — use save-generated with thumbnail
              resultDiv.querySelector("#gen-save-btn")?.addEventListener("click", async () => {
                const saveBtn = resultDiv.querySelector("#gen-save-btn") as HTMLButtonElement;
                saveBtn.disabled = true;
                saveBtn.textContent = "Saving...";
                try {
                  const token = this.getAuthToken();
                  const saveRes = await fetch(`${this._basePath}/api/splats/save-generated`, {
                    method: "POST",
                    headers: {
                      "Content-Type": "application/json",
                      ...(token ? { Authorization: `Bearer ${token}` } : {}),
                    },
                    body: JSON.stringify({
                      url: job.url,
                      title,
                      description: "AI-generated 3D model via Hunyuan3D",
                      thumbnail_url: sourceImage || undefined,
                    }),
                  });

                  if (saveRes.ok) {
                    const data = await saveRes.json() as { slug: string };
                    saveBtn.textContent = "Saved!";
                    saveBtn.style.background = "#16a34a";
                    setTimeout(() => {
                      window.location.href = `${this._basePath}/${data.slug}`;
                    }, 1000);
                  } else {
                    const err = await saveRes.json().catch(() => ({ error: "Save failed" }));
                    saveBtn.textContent = (err as any).error || "Save failed";
                    saveBtn.disabled = false;
                  }
                } catch {
                  saveBtn.textContent = "Save failed";
                  saveBtn.disabled = false;
                }
              });

            } else if (job.status === "failed") {
              stopTicker();
              clearInterval(pollInterval);
              progress.style.display = "none";
              status.textContent = job.error || "Generation failed";
              actions.style.display = "flex";
              submitBtn.disabled = false;

            } else if (job.status === "processing") {
              status.textContent = "Generating 3D model — this may take a few minutes...";
            }
          } catch {
            // Poll error — keep trying
          }
        }, 3000);

      } catch (e: any) {
        clearInterval(ticker);
        document.removeEventListener("visibilitychange", onVisChange);
        status.textContent = e?.message || "Network error — could not reach server";
        progress.style.display = "none";
        actions.style.display = "flex";
        submitBtn.disabled = false;
      }
    });
  }

  // ── Auto-save after generation ──

  private async autoSave() {
    const token = this.getAuthToken();
    if (!token || !this._generatedUrl || this._spaceSlug === "demo") return;

    try {
      const res = await fetch(`${this._basePath}/api/splats/save-generated`, {
        method: "POST",
        headers: {
          "Content-Type": "application/json",
          Authorization: `Bearer ${token}`,
        },
        body: JSON.stringify({
          url: this._generatedUrl,
          title: this._generatedTitle || "AI Generated Model",
          description: "AI-generated 3D model via Hunyuan3D",
          thumbnail_url: (this as any)._sourceImageUrl || undefined,
        }),
      });

      if (res.ok) {
        const data = await res.json() as { slug: string };
        this._savedSlug = data.slug;
      }
    } catch { /* auto-save is best-effort */ }
  }

  // ── Viewer ──

  private renderViewer() {
    const backEl = this._inlineViewer
      ? `<button class="splat-viewer__back" id="splat-back-btn">← Back to Gallery</button>`
      : `<a class="splat-viewer__back" href="${this._basePath}">← Gallery</a>`;

    // Show "View in Gallery" if auto-saved, otherwise "Save" if generated
    let actionEl = "";
    if (this._savedSlug) {
      actionEl = `<a class="splat-viewer__save" href="${this._basePath}/${this._savedSlug}">View in Gallery</a>`;
    } else if (this._generatedUrl && this._inlineViewer && this._spaceSlug !== "demo") {
      actionEl = `<button class="splat-viewer__save" id="splat-save-btn">Save to Gallery</button>`;
    }

    // Download button
    const downloadLabel = this._splatUrl.endsWith(".glb") ? "Download GLB"
      : this._splatUrl.endsWith(".ply") ? "Download PLY"
      : this._splatUrl.endsWith(".splat") ? "Download SPLAT"
      : this._splatUrl.endsWith(".spz") ? "Download SPZ"
      : "Download";
    const downloadEl = `<button class="splat-viewer__download" id="splat-download-btn">${downloadLabel}</button>`;

    // Format info
    const formatExt = this._splatUrl.split(".").pop()?.toUpperCase() || "";
    const formatInfo = formatExt === "GLB" ? "GLB format — opens in Blender, Windows 3D Viewer, Unity"
      : formatExt === "PLY" ? "PLY format — opens in MeshLab, CloudCompare, Blender"
      : formatExt === "SPLAT" ? "SPLAT format — Gaussian splat point cloud"
      : "";

    this.innerHTML = `
      <div class="splat-viewer">
        <div class="splat-loading" id="splat-loading">
          <div class="splat-loading__spinner"></div>
          <div class="splat-loading__text">Loading 3D model...</div>
        </div>
        <div class="splat-viewer__controls">
          ${backEl}
          ${actionEl}
          ${downloadEl}
        </div>
        ${this._splatTitle ? `
          <div class="splat-viewer__info">
            <p class="splat-viewer__title">${esc(this._splatTitle)}</p>
            ${this._splatDesc ? `<p class="splat-viewer__desc">${esc(this._splatDesc)}</p>` : ""}
            ${formatInfo ? `<p class="splat-viewer__format-info">${formatInfo}</p>` : ""}
          </div>
        ` : ""}
        <div id="splat-container" class="splat-viewer__canvas"></div>
      </div>
    `;

    if (this._inlineViewer) {
      this.querySelector("#splat-back-btn")?.addEventListener("click", () => {
        this.cleanupViewer();
        this._mode = "gallery";
        this._inlineViewer = false;
        this._splatUrl = "";
        this._splatTitle = "";
        this._splatDesc = "";
        this._generatedUrl = "";
        this._generatedTitle = "";
        this._savedSlug = "";
        if (this._spaceSlug === "demo") this.loadDemoData();
        this.renderGallery();
      });
    }

    if (!this._savedSlug && this._generatedUrl && this._inlineViewer && this._spaceSlug !== "demo") {
      this.querySelector("#splat-save-btn")?.addEventListener("click", () => this.saveToGallery());
    }

    // Download handler
    this.querySelector("#splat-download-btn")?.addEventListener("click", () => this.downloadModel());

    requestAnimationFrame(() => this.initThreeViewer());
  }

  private async downloadModel() {
    const btn = this.querySelector("#splat-download-btn") as HTMLButtonElement;
    if (!btn || !this._splatUrl) return;

    btn.disabled = true;
    btn.textContent = "Downloading...";

    try {
      const res = await fetch(this._splatUrl);
      if (!res.ok) throw new Error("Download failed");

      const blob = await res.blob();
      const filename = this._splatUrl.split("/").pop() || "model.glb";
      const url = URL.createObjectURL(blob);
      const a = document.createElement("a");
      a.href = url;
      a.download = filename;
      document.body.appendChild(a);
      a.click();
      document.body.removeChild(a);
      URL.revokeObjectURL(url);

      btn.textContent = "Downloaded!";
      setTimeout(() => {
        const ext = this._splatUrl.split(".").pop()?.toUpperCase() || "";
        btn.textContent = `Download ${ext}`;
        btn.disabled = false;
      }, 2000);
    } catch {
      btn.textContent = "Download failed";
      setTimeout(() => {
        const ext = this._splatUrl.split(".").pop()?.toUpperCase() || "";
        btn.textContent = `Download ${ext}`;
        btn.disabled = false;
      }, 2000);
    }
  }

  private cleanupViewer() {
    if (this._viewer) {
      try { this._viewer.dispose(); } catch {}
      this._viewer = null;
    }
    if ((this as any)._glbCleanup) {
      try { (this as any)._glbCleanup(); } catch {}
      (this as any)._glbCleanup = null;
    }
  }

  private getAuthToken(): string {
    return localStorage.getItem("encryptid_token")
      || document.cookie.match(/encryptid_token=([^;]+)/)?.[1]
      || "";
  }

  private async saveToGallery() {
    const saveBtn = this.querySelector("#splat-save-btn") as HTMLButtonElement;
    if (!saveBtn || !this._generatedUrl) return;

    saveBtn.disabled = true;
    saveBtn.textContent = "Saving...";

    try {
      const token = this.getAuthToken();
      if (!token) {
        saveBtn.textContent = "Sign in to save";
        saveBtn.disabled = false;
        return;
      }

      const res = await fetch(`${this._basePath}/api/splats/save-generated`, {
        method: "POST",
        headers: {
          "Content-Type": "application/json",
          Authorization: `Bearer ${token}`,
        },
        body: JSON.stringify({
          url: this._generatedUrl,
          title: this._generatedTitle || "AI Generated Model",
          description: "AI-generated 3D model via Hunyuan3D",
          thumbnail_url: (this as any)._sourceImageUrl || undefined,
        }),
      });

      if (res.status === 401) {
        saveBtn.textContent = "Session expired — sign in again";
        saveBtn.disabled = false;
        return;
      }

      if (!res.ok) {
        const err = await res.json().catch(() => ({ error: "Save failed" }));
        saveBtn.textContent = (err as any).error || "Save failed";
        setTimeout(() => {
          saveBtn.textContent = "Save to Gallery";
          saveBtn.disabled = false;
        }, 2000);
        return;
      }

      const data = await res.json() as { slug: string };
      this._savedSlug = data.slug;
      saveBtn.textContent = "Saved!";
      this._generatedUrl = "";

      // Replace save button with view link
      setTimeout(() => {
        window.location.href = `${this._basePath}/${data.slug}`;
      }, 800);
    } catch {
      saveBtn.textContent = "Network error";
      setTimeout(() => {
        saveBtn.textContent = "Save to Gallery";
        saveBtn.disabled = false;
      }, 2000);
    }
  }

  private async initThreeViewer() {
    const container = this.querySelector("#splat-container") as HTMLElement;
    const loading = this.querySelector("#splat-loading") as HTMLElement;

    if (!container || !this._splatUrl) return;

    const isGlb = this._splatUrl.endsWith(".glb") || this._splatUrl.endsWith(".gltf");

    try {
      if (isGlb) {
        await this.initGlbViewer(container, loading);
      } else {
        await this.initSplatViewer(container, loading);
      }
    } catch (e) {
      console.error("[rSplat] Viewer init error:", e);
      if (loading) {
        const text = loading.querySelector(".splat-loading__text");
        if (text) text.textContent = `Error loading viewer: ${(e as Error).message}`;
        const spinner = loading.querySelector(".splat-loading__spinner") as HTMLElement;
        if (spinner) spinner.style.display = "none";
      }
    }
  }

  private async initSplatViewer(container: HTMLElement, loading: HTMLElement | null) {
    const GaussianSplats3D = await import("@mkkellogg/gaussian-splats-3d");

    const isMobile = /Android|iPhone|iPad|iPod/i.test(navigator.userAgent) ||
      (navigator.maxTouchPoints > 1 && window.innerWidth < 1024);

    const viewer = new GaussianSplats3D.Viewer({
      cameraUp: [0, 1, 0],
      initialCameraPosition: [5, 3, 5],
      initialCameraLookAt: [0, 0, 0],
      rootElement: container,
      sharedMemoryForWorkers: false,
      halfPrecisionCovariancesOnGPU: isMobile,
      dynamicScene: false,
      ...(isMobile ? { splatSortDistanceMapPrecision: 16 } : {}),
    });

    this._viewer = viewer;

    const LOAD_TIMEOUT = isMobile ? 30_000 : 60_000;
    let loaded = false;

    const timeoutId = setTimeout(() => {
      if (loaded) return;
      console.warn("[rSplat] Load timed out after", LOAD_TIMEOUT, "ms");
      if (loading) {
        const text = loading.querySelector(".splat-loading__text");
        if (text) text.textContent = "Loading is taking longer than expected. The file may be too large for this device.";
        const spinner = loading.querySelector(".splat-loading__spinner") as HTMLElement;
        if (spinner) spinner.style.display = "none";
      }
    }, LOAD_TIMEOUT);

    viewer.addSplatScene(this._splatUrl!, {
      showLoadingUI: false,
      progressiveLoad: true,
    })
    .then(() => {
      loaded = true;
      clearTimeout(timeoutId);
      viewer.start();
      if (loading) loading.classList.add("hidden");
    })
    .catch((e: Error) => {
      loaded = true;
      clearTimeout(timeoutId);
      console.error("[rSplat] Scene load error:", e);
      if (loading) {
        const text = loading.querySelector(".splat-loading__text");
        if (text) text.textContent = `Error: ${e.message}`;
        const spinner = loading.querySelector(".splat-loading__spinner") as HTMLElement;
        if (spinner) spinner.style.display = "none";
      }
    });
  }

  private async initGlbViewer(container: HTMLElement, loading: HTMLElement | null) {
    const THREE: any = await import("three");
    // @ts-expect-error — three.js addons loaded via CDN importmap, no bundled types
    const { OrbitControls } = await import("three/addons/controls/OrbitControls.js");
    // @ts-expect-error — three.js addons loaded via CDN importmap, no bundled types
    const { GLTFLoader } = await import("three/addons/loaders/GLTFLoader.js");

    // Wait for layout if container has no dimensions yet
    if (!container.clientWidth || !container.clientHeight) {
      await new Promise((r) => requestAnimationFrame(r));
    }
    const w = container.clientWidth || window.innerWidth;
    const h = container.clientHeight || (window.innerHeight - 56);

    const scene = new THREE.Scene();
    scene.background = new THREE.Color(0x0f0f14);

    const camera = new THREE.PerspectiveCamera(50, w / h, 0.01, 1000);
    camera.position.set(3, 2, 3);

    const renderer = new THREE.WebGLRenderer({ antialias: true });
    renderer.setSize(w, h);
    renderer.setPixelRatio(window.devicePixelRatio);
    renderer.toneMapping = THREE.ACESFilmicToneMapping;
    renderer.toneMappingExposure = 1.2;
    container.appendChild(renderer.domElement);

    const controls = new OrbitControls(camera, renderer.domElement);
    controls.enableDamping = true;
    controls.dampingFactor = 0.08;
    controls.target.set(0, 0, 0);

    // Lighting
    const ambient = new THREE.AmbientLight(0xffffff, 0.6);
    scene.add(ambient);
    const dirLight = new THREE.DirectionalLight(0xffffff, 1.2);
    dirLight.position.set(5, 10, 7);
    scene.add(dirLight);
    const fillLight = new THREE.DirectionalLight(0x8888ff, 0.4);
    fillLight.position.set(-5, 3, -5);
    scene.add(fillLight);

    // Load GLB
    const loader = new GLTFLoader();
    loader.load(
      this._splatUrl!,
      (gltf: any) => {
        const model = gltf.scene;
        // Auto-center and scale
        const box = new THREE.Box3().setFromObject(model);
        const center = box.getCenter(new THREE.Vector3());
        const size = box.getSize(new THREE.Vector3());
        const maxDim = Math.max(size.x, size.y, size.z);
        const scale = 2 / maxDim;
        model.scale.setScalar(scale);
        model.position.sub(center.multiplyScalar(scale));
        scene.add(model);

        controls.target.set(0, 0, 0);
        controls.update();

        if (loading) loading.classList.add("hidden");
      },
      undefined,
      (err: any) => {
        console.error("[rSplat] GLB load error:", err);
        if (loading) {
          const text = loading.querySelector(".splat-loading__text");
          if (text) text.textContent = `Error loading GLB: ${err.message || err}`;
          const spinner = loading.querySelector(".splat-loading__spinner") as HTMLElement;
          if (spinner) spinner.style.display = "none";
        }
      }
    );

    // Animation loop
    let animId: number;
    const animate = () => {
      animId = requestAnimationFrame(animate);
      controls.update();
      renderer.render(scene, camera);
    };
    animate();

    // Handle resize
    const onResize = () => {
      const rw = container.clientWidth || 800;
      const rh = container.clientHeight || 600;
      camera.aspect = rw / rh;
      camera.updateProjectionMatrix();
      renderer.setSize(rw, rh);
    };
    window.addEventListener("resize", onResize);

    // Store cleanup reference
    (this as any)._glbCleanup = () => {
      cancelAnimationFrame(animId);
      window.removeEventListener("resize", onResize);
      controls.dispose();
      renderer.dispose();
    };
  }

  startTour() { this._tour?.start(); }
}

// ── Helpers ──

function esc(s: string): string {
  return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
}

function formatSize(bytes: number): string {
  if (bytes < 1024) return `${bytes} B`;
  if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
  return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}

customElements.define("folk-splat-viewer", FolkSplatViewer);
