/* Shared utilities, constants, prompts and storage helpers.
   Exported to window so other Babel scripts can use them. */

const { useState, useEffect, useMemo, useRef, useCallback } = React;

const STORAGE_KEY_TEXT   = "linkedin_posts_v1";
const STORAGE_KEY_VISUAL = "linkedin_visual_posts_v1";

/** Local dev host — layout explorer, Built-in Claude, and Ollama are localhost-only. */
function isLocalDevHost() {
  if (typeof location === "undefined") return false;
  const h = location.hostname.toLowerCase();
  if (h === "localhost" || h === "127.0.0.1") return true;
  if (location.protocol === "file:") return true;
  return false;
}
function isLayoutExplorerEnabled() {
  return isLocalDevHost();
}

/** YouTube walkthrough — linked in header, banner, footer, and Settings */
const APP_DEMO_VIDEO_URL = "https://youtu.be/PiPClWY6VgA";

/* Creator profile & support links (shown in app footer and Settings) */
const CREATOR_LINKS = [
  {
    id: "demo-video",
    label: "Watch demo",
    emoji: "🎬▶️",
    tagline: "7-minute walkthrough — social posts, visual export, and API keys.",
    url: APP_DEMO_VIDEO_URL,
    icon: "ti-player-play",
  },
  {
    id: "youtube",
    label: "YouTube",
    emoji: "▶️🤖",
    tagline: "A launchpad where AI learning becomes real demos.",
    url: "https://www.youtube.com/@itaienthusiast",
    icon: "ti-brand-youtube",
  },
  {
    id: "github",
    label: "GitHub",
    emoji: "🧠📚",
    tagline: "Your codebase, turned into a knowledge vault.",
    url: "https://github.com/mayankchugh-learning",
    icon: "ti-brand-github",
  },
  {
    id: "portfolio",
    label: "Portfolio",
    emoji: "🌐🕹️",
    tagline: "A digital control room for building something real.",
    url: "https://mayankchugh-learning.github.io/",
    icon: "ti-world",
  },
  {
    id: "linkedin",
    label: "LinkedIn",
    emoji: "🤝🛰️",
    tagline: "A network of signals connecting builders and teams.",
    url: "https://www.linkedin.com/in/mchugh77",
    icon: "ti-brand-linkedin",
  },
  {
    id: "buymeacoffee",
    label: "Buy Me a Coffee",
    emoji: "☕⚡",
    tagline: "Fuel for more experiments and production-ready systems.",
    url: "https://buymeacoffee.com/mayankchugh?new=1",
    icon: "ti-coffee",
  },
  {
    id: "book-call",
    label: "Book a call",
    emoji: "📞🗓️",
    tagline: "A human-to-AI roadmap session.",
    url: "https://www.linkedin.com/in/mchugh77",
    icon: "ti-calendar-event",
  },
  {
    id: "huggingface",
    label: "Hugging Face",
    emoji: "🤗🧪",
    tagline: "A library of deployed experiments and live AI apps.",
    url: "https://huggingface.co/spaces/mayankchugh-learning",
    icon: "ti-sparkles",
  },
  {
    id: "medium",
    label: "Medium",
    emoji: "✍️📚",
    tagline: "Where RAG patterns become readable playbooks.",
    url: "https://medium.com/@mayankchugh.jobathk",
    icon: "ti-article",
  },
  {
    id: "gumroad",
    label: "Gumroad",
    emoji: "🛒📘",
    tagline: "Digital playbooks for builders — 50% off all products right now.",
    badge: "50% OFF",
    url: "https://mayanklearns.gumroad.com/",
    icon: "ti-shopping-cart",
  },
];

const STYLES = [
  { id: "blank",            label: "Blank / your draft",  icon: "ti-file-text" },
  { id: "personal-story",   label: "Personal story",      icon: "ti-user-heart" },
  { id: "tips-lessons",     label: "Tips / lessons",      icon: "ti-bulb" },
  { id: "scroll-stopper",   label: "Scroll-stopper hook", icon: "ti-bolt" },
  { id: "carousel-script",  label: "Carousel script",     icon: "ti-layout-grid" },
  { id: "contrarian-take",  label: "Contrarian take",     icon: "ti-arrows-cross" },
];

const TONES = [
  { id: "professional",     label: "Professional" },
  { id: "casual-relatable", label: "Casual & relatable" },
  { id: "bold-direct",      label: "Bold & direct" },
  { id: "inspirational",    label: "Inspirational" },
];

/* ---------------- curated visual resource presets ---------------- */
const VISUAL_RESOURCE_PRESETS = [
  {
    id: "ai-model-types-overview",
    label: "8 AI model types",
    niche: "AI engineering",
    audience: "AI builders and software engineers",
    topic: "The 8 AI model types powering modern AI agents (GPT, MoE, LRM, VLM, SLM, LAM, HLM, LCM)",
    byline: "",
  },
  {
    id: "single-vs-multi-model",
    label: "Single vs multi-model",
    niche: "AI systems design",
    audience: "Tech leads and startup founders",
    topic: "Single-model vs multi-model AI architecture: where speed, quality, and cost diverge",
    byline: "",
  },
  {
    id: "gpt-vs-moe-vs-lrm",
    label: "GPT vs MoE vs LRM",
    niche: "LLM architecture",
    audience: "ML engineers",
    topic: "When to use GPT, MoE, and LRM in production AI systems",
    byline: "",
  },
  {
    id: "vlm-and-image-workflows",
    label: "VLM use cases",
    niche: "Multimodal AI",
    audience: "Product teams building AI UX",
    topic: "How VLMs turn screenshots, documents, and images into actionable product workflows",
    byline: "",
  },
  {
    id: "slm-edge-ai",
    label: "SLM for edge AI",
    niche: "Edge AI",
    audience: "Mobile and embedded engineers",
    topic: "Why SLMs are critical for private, low-latency, on-device AI experiences",
    byline: "",
  },
  {
    id: "lam-action-execution",
    label: "LAM execution layer",
    niche: "Agentic automation",
    audience: "Operations and automation leaders",
    topic: "From answer generation to action execution: where LAMs create real business outcomes",
    byline: "",
  },
  {
    id: "hlm-and-orchestration",
    label: "HLM planning",
    niche: "AI orchestration",
    audience: "AI platform engineers",
    topic: "How HLMs coordinate complex multi-agent workflows with dependencies",
    byline: "",
  },
  {
    id: "cost-routing-playbook",
    label: "Cost routing strategy",
    niche: "AI economics",
    audience: "CTOs and AI product managers",
    topic: "The smallest-fastest-cheapest routing strategy that can cut AI costs by 60-80%",
    byline: "",
  },
];

const VISUAL_LAYOUT_FAMILIES = [
  {
    id: "portrait-4x5-static",
    label: "Portrait 4:5 (static)",
    guidance: "Design a single-slide static infographic optimized for 4:5 portrait feed posts.",
  },
  {
    id: "portrait-4x5-carousel",
    label: "Portrait 4:5 carousel",
    guidance: "Design content with clear step-wise progression suitable for multi-page carousel exports.",
  },
  {
    id: "portrait-tall-3x4",
    label: "Tall portrait 3:4",
    guidance: "Use a taller vertical layout with more room for stacked modules and denser content.",
  },
  {
    id: "square-1x1",
    label: "Square 1:1",
    guidance: "Keep composition centered and balanced for square posts with concise text blocks.",
  },
  {
    id: "story-9x16",
    label: "Story/Reel 9:16",
    guidance: "Use a mobile-first vertical narrative with high-contrast sections and large text hierarchy.",
  },
  {
    id: "animated-motion-gif",
    label: "Animated GIF flow",
    guidance: "Structure content as sequenced beats that can animate between states without text overload per frame.",
  },
  {
    id: "document-pdf-deck",
    label: "PDF deck (multi-page)",
    guidance: "Design as a document-style deck with clear sectioning, page-to-page continuity, and reusable headings.",
  },
  {
    id: "landscape-16x9",
    label: "Landscape 16:9",
    guidance: "Use a wide layout emphasizing comparisons, timelines, and side-by-side frameworks.",
  },
];

function styleLabel(id) { return (STYLES.find(s => s.id === id) || {}).label || id; }
function toneLabel(id)  { return (TONES .find(t => t.id === id) || {}).label || id; }

/* ---------------- storage ---------------- */
async function loadFromStorage(key) {
  try {
    if (!window.storage || !window.storage.get) return [];
    const raw = await window.storage.get(key);
    if (!raw) return [];
    const parsed = typeof raw === "string" ? JSON.parse(raw) : raw;
    return Array.isArray(parsed) ? parsed : [];
  } catch (err) {
    console.warn("storage.get failed", key, err);
    return [];
  }
}
async function saveToStorage(key, data) {
  try {
    if (!window.storage || !window.storage.set) return;
    await window.storage.set(key, JSON.stringify(data));
  } catch (err) {
    console.warn("storage.set failed", key, err);
  }
}

/* ---------------- metrics ---------------- */
function postMetrics(text) {
  const chars = text.length;
  const words = text.trim() ? text.trim().split(/\s+/).length : 0;
  const minutes = Math.max(1, Math.round(words / 200));
  return { chars, words, minutes };
}

/* ---------------- platforms ---------------- */
const PLATFORMS = [
  { id: "linkedin",  label: "LinkedIn",  icon: "ti-brand-linkedin" },
  { id: "x",         label: "X",         icon: "ti-brand-x" },
  { id: "facebook",  label: "Facebook",  icon: "ti-brand-facebook" },
  { id: "pinterest", label: "Pinterest", icon: "ti-brand-pinterest" },
  { id: "reddit",    label: "Reddit",    icon: "ti-brand-reddit" },
];

const PLATFORM_LIMITS = {
  linkedin:  3000,
  x:         280,
  facebook:  63206,
  pinterest: 500,
  reddit:    40000,
};

function getPostMemorySnippet(post) {
  if (post.platforms) {
    const bits = [];
    if (post.platforms.linkedin) bits.push(`LinkedIn: ${String(post.platforms.linkedin).slice(0, 220)}`);
    if (post.platforms.x) bits.push(`X: ${post.platforms.x}`);
    if (post.platforms.facebook) bits.push(`Facebook: ${String(post.platforms.facebook).slice(0, 180)}`);
    if (post.platforms.reddit) bits.push(`Reddit: ${String(getPlatformText({ platforms: post.platforms }, "reddit")).slice(0, 220)}`);
    return bits.join("\n");
  }
  return post.content || "";
}

function getPlatformText(post, platformId) {
  if (post.platforms) {
    const val = post.platforms[platformId];
    if (!val) return "";
    if (platformId === "pinterest" && typeof val === "object") {
      const title = val.title || "";
      const desc = val.description || "";
      return title && desc ? `${title}\n\n${desc}` : title || desc;
    }
    if (platformId === "reddit" && typeof val === "object") {
      const title = val.title || "";
      const body = val.body || val.text || "";
      return title && body ? `${title}\n\n${body}` : title || body;
    }
    return String(val);
  }
  if (platformId === "linkedin") return post.content || "";
  return "";
}

const PLATFORM_TAG_LIMITS = {
  linkedin: 12,
  x: 4,
  facebook: 6,
  pinterest: 10,
  reddit: 3,
};

function normalizeTagList(arr, max) {
  if (!Array.isArray(arr)) return [];
  return arr
    .map(t => String(t).trim().replace(/^#+/, ""))
    .filter(Boolean)
    .slice(0, max);
}

function normalizePlatformTags(raw) {
  if (!raw || typeof raw !== "object") return null;
  const out = {};
  for (const p of PLATFORMS) {
    const max = PLATFORM_TAG_LIMITS[p.id] || 10;
    const list = normalizeTagList(raw[p.id], max);
    if (list.length) out[p.id] = list;
  }
  return Object.keys(out).length ? out : null;
}

function extractHashtagsFromText(text) {
  if (!text) return [];
  const matches = String(text).match(/#([\w\u00C0-\u024F]+)/g) || [];
  return [...new Set(matches.map(m => m.slice(1)))];
}

function enrichTagsFromPostBodies(platforms, tags) {
  const out = tags ? { ...tags } : {};
  for (const p of PLATFORMS) {
    if (out[p.id]?.length) continue;
    const text = getPlatformText({ platforms }, p.id);
    const extracted = normalizeTagList(
      extractHashtagsFromText(text),
      PLATFORM_TAG_LIMITS[p.id] || 10
    );
    if (extracted.length) out[p.id] = extracted;
  }
  return Object.keys(out).length ? out : null;
}

function buildFallbackPlatformTags({ niche, topic }) {
  const tokens = [];
  const tokenize = (s) => {
    String(s || "").split(/[\s,/]+/).forEach(w => {
      const t = w.replace(/[^a-zA-Z0-9]/g, "");
      if (t.length >= 3) tokens.push(t.charAt(0).toUpperCase() + t.slice(1).toLowerCase());
    });
  };
  tokenize(topic);
  tokenize(niche);
  const unique = [...new Set(tokens)];
  if (unique.length < 2) {
    unique.push("LinkedInTips", "ContentCreation", "ProfessionalGrowth");
  }
  return {
    linkedin: unique.slice(0, PLATFORM_TAG_LIMITS.linkedin),
    x: unique.slice(0, PLATFORM_TAG_LIMITS.x),
    facebook: unique.slice(0, PLATFORM_TAG_LIMITS.facebook),
    pinterest: unique.slice(0, PLATFORM_TAG_LIMITS.pinterest),
    reddit: unique.slice(0, PLATFORM_TAG_LIMITS.reddit),
  };
}

function getPlatformTags(post, platformId) {
  if (!platformId) return [];
  const stored = post?.tags?.[platformId];
  if (stored?.length) return stored;
  const fromBody = normalizeTagList(
    extractHashtagsFromText(getPlatformText(post, platformId)),
    PLATFORM_TAG_LIMITS[platformId] || 10
  );
  return fromBody;
}

function formatHashtags(tagList) {
  const list = Array.isArray(tagList) ? tagList : [];
  return list.map(t => (t.startsWith("#") ? t : `#${t}`)).join(" ");
}

function formatPlatformTextWithTags(post, platformId) {
  const body = getPlatformText(post, platformId);
  const tags = formatHashtags(getPlatformTags(post, platformId));
  if (!body) return tags;
  if (!tags) return body;
  return `${body}\n\n${tags}`;
}

function formatAllPlatforms(post) {
  return PLATFORMS.map(p => {
    const text = formatPlatformTextWithTags(post, p.id);
    return text ? `=== ${p.label} ===\n${text}` : "";
  }).filter(Boolean).join("\n\n");
}

function tryParseEmbeddedPlatformObject(value) {
  if (typeof value !== "string") return null;
  const s = value.trim();
  if (!s) return null;

  // Case 1: plain JSON-ish string containing platform keys.
  const direct = extractJson(s);
  if (direct && typeof direct === "object" && (direct.linkedin || direct.x || direct.facebook || direct.pinterest || direct.reddit)) {
    return direct;
  }

  // Case 2: escaped JSON text (e.g. {\"linkedin\":\"...\"}).
  if (s.includes('\\"') || s.includes("\\n")) {
    const unescaped = s
      .replace(/\\"/g, '"')
      .replace(/\\n/g, "\n")
      .replace(/\\t/g, "\t")
      .replace(/\\\\/g, "\\");
    const parsed = extractJson(unescaped);
    if (parsed && typeof parsed === "object" && (parsed.linkedin || parsed.x || parsed.facebook || parsed.pinterest || parsed.reddit)) {
      return parsed;
    }
  }
  return null;
}

function normalizePlatformPosts(raw) {
  if (!raw || typeof raw !== "object") return null;
  // Recover when a model nests the full object inside a single platform string.
  const embedded =
    tryParseEmbeddedPlatformObject(raw.linkedin) ||
    tryParseEmbeddedPlatformObject(raw.facebook) ||
    tryParseEmbeddedPlatformObject(raw.x) ||
    tryParseEmbeddedPlatformObject(raw.reddit);
  if (embedded) return normalizePlatformPosts(embedded);

  const out = {};
  if (raw.linkedin) out.linkedin = String(raw.linkedin).trim();
  if (raw.x) out.x = String(raw.x).trim();
  if (raw.facebook) out.facebook = String(raw.facebook).trim();
  if (raw.reddit) {
    if (typeof raw.reddit === "object") {
      out.reddit = {
        title: String(raw.reddit.title || "").trim(),
        body: String(raw.reddit.body || raw.reddit.text || "").trim(),
      };
    } else {
      const s = String(raw.reddit).trim();
      const nl = s.indexOf("\n");
      out.reddit = nl > -1
        ? { title: s.slice(0, nl).trim(), body: s.slice(nl + 1).trim() }
        : { title: s.slice(0, 140).trim(), body: s };
    }
  }
  if (raw.pinterest) {
    if (typeof raw.pinterest === "object") {
      out.pinterest = {
        title: String(raw.pinterest.title || "").trim(),
        description: String(raw.pinterest.description || "").trim(),
      };
    } else {
      const s = String(raw.pinterest).trim();
      const nl = s.indexOf("\n");
      out.pinterest = nl > -1
        ? { title: s.slice(0, nl).trim(), description: s.slice(nl + 1).trim() }
        : { title: s.slice(0, 100), description: s };
    }
  }
  const hasAny = Object.keys(out).length > 0;
  return hasAny ? out : null;
}

function getPostPreviewText(post) {
  const t = getPlatformText(post, "linkedin") || getPlatformText(post, "x") || getPlatformText(post, "facebook");
  return t.replace(/\s+/g, " ").slice(0, 180);
}

/* ---------------- prompts ---------------- */
const TEXT_SYSTEM_PROMPT = `You are an expert social media ghostwriter. Adapt one topic into platform-native posts for LinkedIn, X, Facebook, Pinterest, and Reddit.

OUTPUT FORMAT — strict JSON only, no markdown fences, no preamble:
{
  "linkedin": "string",
  "x": "string",
  "facebook": "string",
  "pinterest": { "title": "string", "description": "string" },
  "reddit": { "title": "string", "body": "string" },
  "tags": {
    "linkedin": ["tagWithoutHash", "..."],
    "x": ["tagWithoutHash", "..."],
    "facebook": ["tagWithoutHash", "..."],
    "pinterest": ["keywordWithoutHash", "..."],
    "reddit": ["tagWithoutHash", "..."]
  }
}

PLATFORM RULES (post body — no hashtags inline; put all hashtags/keywords in "tags" only):
- linkedin: 150–300 words. Hook on line 1. Short paragraphs with blank lines. 2–4 emojis max. End with CTA or question. Professional but human.
- x: Under 250 characters total (hard max 280). Punchy, one clear idea. 0–2 emojis.
- facebook: 80–180 words. Conversational, community tone. Line breaks for mobile. 1–3 emojis ok.
- pinterest.title: Under 100 characters, keyword-rich, curiosity-driven.
- pinterest.description: 2–4 sentences, searchable language, actionable. Under 500 characters.
- reddit.title: Under 150 characters, useful and discussion-oriented.
- reddit.body: 120–280 words, practical and non-promotional. End with a question to invite comments.

TAG RULES (required "tags" object — strings WITHOUT leading #):
- linkedin: 8–12 relevant hashtags (mix broad + niche).
- x: 2–4 short hashtags that fit within character limits when appended.
- facebook: 3–6 discovery hashtags.
- pinterest: 5–10 SEO keywords (no spaces in multi-word tags; use CamelCase or single words).
- reddit: 0–3 topical tags only (no spam); use [] sparingly or leave empty array if none fit.

GLOBAL:
- Same core message and angle across platforms, but each must feel native — do not copy-paste identical post bodies.
- Match the requested tone and structural style.
- No corporate clichés. Sound human, not AI.`;

function buildTextUserPrompt({ niche, topic, style, tone, audience, recentPosts, extraNotes, sourceContent }) {
  const lines = [];
  const hasDraft = !!(sourceContent && sourceContent.trim());
  const isBlank = style === "blank";

  if (hasDraft) {
    lines.push(`Improve and adapt the user's SOURCE DRAFT into platform-native posts.`);
    lines.push(`Preserve their facts, examples, numbered sections, and voice — do NOT invent unrelated claims.`);
    lines.push(`Sharpen hooks, tighten phrasing, improve paragraph breaks, and fix awkward wording.`);
    if (isBlank) {
      lines.push(`BLANK TEMPLATE: Keep the full depth of the source. LinkedIn may be 400–900 words if warranted.`);
      lines.push(`Retain numbered lists, checkmarks, and section structure. Move hashtags from the draft into the "tags" object (do not duplicate inline in post bodies).`);
    } else {
      lines.push(`FIT TO STYLE: Restructure the source into the "${styleLabel(style)}" format while keeping the core message intact.`);
    }
  } else {
    lines.push(`Write posts for LinkedIn, X, Facebook, Pinterest, and Reddit from this brief.`);
  }
  lines.push(``);
  lines.push(`Niche / area of expertise: ${niche || "(unspecified)"}`);
  lines.push(`Post topic: ${topic || (hasDraft ? "(derived from draft)" : "(unspecified)")}`);
  lines.push(`Structural style: ${styleLabel(style)}`);
  lines.push(`Tone of voice: ${toneLabel(tone)}`);
  lines.push(`Target audience: ${audience || "(unspecified)"}`);

  if (hasDraft) {
    lines.push(``);
    lines.push(`=== SOURCE DRAFT ===`);
    lines.push(sourceContent.trim());
    lines.push(`=== END SOURCE DRAFT ===`);
  }

  if (extraNotes) {
    lines.push(``);
    lines.push(extraNotes);
  }

  if (recentPosts && recentPosts.length) {
    lines.push(``);
    lines.push(`=== ANTI-REPEAT MEMORY ===`);
    lines.push(`Below are the ${recentPosts.length} most recently generated bundles for this user.`);
    lines.push(`Do NOT repeat topics, hooks, opening lines, angles, frameworks, examples, or structural patterns.`);
    lines.push(``);
    recentPosts.forEach((p, i) => {
      lines.push(`--- Previous bundle #${i + 1} (topic: "${p.topic}") ---`);
      lines.push(getPostMemorySnippet(p));
      lines.push(``);
    });
    lines.push(`=== END MEMORY ===`);
  }

  lines.push(``);
  lines.push(`Return only the JSON object with keys linkedin, x, facebook, pinterest, reddit, and tags.`);
  return lines.join("\n");
}

async function generatePlatformPosts({ settings, niche, topic, style, tone, audience, recentPosts, extraNotes, sourceContent }) {
  if (!window.callAI) throw new Error("AI provider not loaded.");
  const hasDraft = !!(sourceContent && sourceContent.trim());
  const isBlank = style === "blank";
  let system = TEXT_SYSTEM_PROMPT;
  if (hasDraft && isBlank) {
    system += `\n\nDRAFT IMPROVEMENT MODE (blank template):
- linkedin: Preserve full structure and depth from the source. 400–900 words is fine for long-form posts. Keep numbered sections, short paragraphs, blank lines between blocks. End with CTA or question.
- tags.linkedin: Extract hashtags from the draft or add 10–12 relevant ones (no # prefix in JSON).
- Other platforms: Compress the same core message natively — do not copy the full LinkedIn length to X or Pinterest.`;
  } else if (hasDraft) {
    system += `\n\nDRAFT IMPROVEMENT MODE: Adapt the source draft into each platform's format and the requested structural style. Preserve facts and voice.`;
  }
  const draftLen = hasDraft ? sourceContent.trim().length : 0;
  const maxTokens = draftLen > 2000 ? 4500 : draftLen > 800 ? 3500 : 2800;
  const raw = await window.callAI({
    settings,
    system,
    user: buildTextUserPrompt({ niche, topic, style, tone, audience, recentPosts, extraNotes, sourceContent }),
    maxTokens,
  });
  if (!raw) throw new Error("Empty response from the model.");
  const parsed = extractJson(raw);
  let platforms = normalizePlatformPosts(parsed);
  if (!platforms) {
    platforms = normalizePlatformPosts({ linkedin: raw.trim() });
  }
  if (!platforms) throw new Error("Couldn't parse platform posts from the model. Try regenerating.");
  let tags = normalizePlatformTags(parsed?.tags);
  tags = enrichTagsFromPostBodies(platforms, tags);
  if (!tags) tags = buildFallbackPlatformTags({ niche, topic });
  return { platforms, tags };
}

/* Extract a JSON object from a free-text Claude reply. Tolerates fenced
   code blocks (```json ... ```) and leading/trailing prose. */
function extractJson(text) {
  if (!text) return null;
  // ```json blocks
  const fenced = text.match(/```(?:json)?\s*([\s\S]*?)\s*```/i);
  let candidate = fenced ? fenced[1] : text;

  // Find the first { and matching last }
  const first = candidate.indexOf("{");
  const last  = candidate.lastIndexOf("}");
  if (first === -1 || last === -1 || last <= first) return null;
  candidate = candidate.slice(first, last + 1);

  try {
    return JSON.parse(candidate);
  } catch (err) {
    // Try a forgiving cleanup: remove trailing commas
    try {
      const cleaned = candidate.replace(/,(\s*[}\]])/g, "$1");
      return JSON.parse(cleaned);
    } catch (err2) {
      console.warn("JSON parse failed", err2, candidate);
      return null;
    }
  }
}

/* ---------------- toast hook ---------------- */
function useToast() {
  const [msg, setMsg] = useState(null);
  const tRef = useRef(null);
  const show = useCallback((text) => {
    setMsg(text);
    clearTimeout(tRef.current);
    tRef.current = setTimeout(() => setMsg(null), 1700);
  }, []);
  const node = msg ? (
    <div className="toast is-visible">
      <i className="ti ti-check"></i>
      <span>{msg}</span>
    </div>
  ) : <div className="toast"></div>;
  return [show, node];
}

/* ---------------- copy helper ---------------- */
async function copyToClipboard(text) {
  try {
    await navigator.clipboard.writeText(text);
    return true;
  } catch {
    return false;
  }
}

/* ---------------- visual export watermark ---------------- */
const EXPORT_WATERMARK_TEXT = "Generated by LinkedIn Post Generator @mayankchugh";

function VisualExportFrame({ width, height, byline, avatarDataUrl, children }) {
  const w = width || 1080;
  const pad = Math.max(10, Math.round(w * 0.014));
  const fontSize = Math.max(11, Math.round(w * 0.013));
  const badgeGap = Math.max(6, Math.round(w * 0.006));
  const avatarSize = Math.max(22, Math.round(w * 0.032));
  const hasBylineBadge = !!(byline || avatarDataUrl);
  return (
    <div style={{ position: "relative", width: w, height: height || undefined, overflow: "hidden" }}>
      {children}
      {hasBylineBadge && (
        <div
          style={{
            position: "absolute",
            left: pad,
            bottom: pad,
            zIndex: 9998,
            pointerEvents: "none",
            display: "inline-flex",
            alignItems: "center",
            gap: badgeGap,
            color: "#0f172a",
            background: "rgba(255, 255, 255, 0.82)",
            padding: `${Math.max(4, Math.round(fontSize * 0.3))}px ${Math.max(8, Math.round(fontSize * 0.7))}px`,
            borderRadius: Math.max(6, Math.round(fontSize * 0.4)),
            boxShadow: "0 1px 3px rgba(0,0,0,0.08)",
            maxWidth: Math.round(w * 0.65),
          }}
        >
          {avatarDataUrl ? (
            <img
              src={avatarDataUrl}
              alt=""
              aria-hidden="true"
              style={{
                width: avatarSize,
                height: avatarSize,
                borderRadius: "50%",
                objectFit: "cover",
                border: "1px solid rgba(15, 23, 42, 0.15)",
                flexShrink: 0,
              }}
            />
          ) : null}
          {byline ? (
            <span style={{ fontFamily: "'Inter', system-ui, sans-serif", fontWeight: 600, fontSize, lineHeight: 1.25, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
              {byline}
            </span>
          ) : null}
        </div>
      )}
      <div
        aria-hidden="true"
        style={{
          position: "absolute",
          right: pad,
          bottom: pad,
          zIndex: 9999,
          pointerEvents: "none",
          fontFamily: "'Inter', system-ui, sans-serif",
          fontSize,
          fontWeight: 500,
          lineHeight: 1.25,
          color: "rgba(15, 23, 42, 0.55)",
          background: "rgba(255, 255, 255, 0.72)",
          padding: `${Math.round(fontSize * 0.35)}px ${Math.round(fontSize * 0.65)}px`,
          borderRadius: Math.max(4, Math.round(fontSize * 0.3)),
          boxShadow: "0 1px 3px rgba(0,0,0,0.08)",
          whiteSpace: "nowrap",
        }}
      >
        {EXPORT_WATERMARK_TEXT}
      </div>
    </div>
  );
}

/* ---------------- PNG download ---------------- */
async function downloadNodeAsPng(node, filename) {
  if (!node || !window.htmlToImage) throw new Error("html-to-image not loaded");
  // Render at 2x for crisp output
  const dataUrl = await window.htmlToImage.toPng(node, {
    pixelRatio: 2,
    cacheBust: true,
    backgroundColor: getComputedStyle(node).backgroundColor || "#ffffff",
  });
  const a = document.createElement("a");
  a.href = dataUrl;
  a.download = filename;
  document.body.appendChild(a);
  a.click();
  document.body.removeChild(a);
}

/* ---------------- GIF export for animated templates ---------------- */
const GIFJS_CDN_WORKER = "https://cdnjs.cloudflare.com/ajax/libs/gif.js/0.2.0/gif.worker.js";
const HOSTED_GIF_MAX_FRAMES = 24;
const LOCAL_GIF_MAX_FRAMES = 90;
const GIF_CAPTURE_TIMEOUT_MS = 20000;
const GIF_ENCODE_TIMEOUT_MS = 120000;

function getGifExportFrameCap() {
  if (typeof window.isLocalDevHost === "function" && window.isLocalDevHost()) {
    return LOCAL_GIF_MAX_FRAMES;
  }
  return HOSTED_GIF_MAX_FRAMES;
}

function resolveGifExportFrames(template, data, maxFrames) {
  const requested = template.getFrameCount(data, { maxFrames: maxFrames || 0 });
  const cap = getGifExportFrameCap();
  if (requested <= cap) {
    return { count: requested, capped: false, requested };
  }
  return { count: cap, capped: true, requested };
}

function resolveGifWorkerScript() {
  try {
    return new URL("gif.worker.js", window.location.href).href;
  } catch {
    return GIFJS_CDN_WORKER;
  }
}

function createGifEncoder(width, height) {
  const hosted = typeof window.isLocalDevHost === "function" && !window.isLocalDevHost();
  if (hosted) {
    return new window.GIF({ workers: 0, quality: 12, width, height });
  }
  const workerCandidates = [resolveGifWorkerScript(), GIFJS_CDN_WORKER];
  const tried = new Set();
  for (const workerScript of workerCandidates) {
    if (tried.has(workerScript)) continue;
    tried.add(workerScript);
    try {
      return new window.GIF({
        workers: 2,
        quality: 10,
        width,
        height,
        workerScript,
      });
    } catch (err) {
      console.warn("GIF worker init failed:", workerScript, err);
    }
  }
  return new window.GIF({ workers: 0, quality: 10, width, height });
}

async function captureReactFrame(renderElement, width, height, backgroundColor) {
  const work = (async () => {
    if (!window.ReactDOM || !window.htmlToImage) throw new Error("Render libraries not loaded");
    const host = document.createElement("div");
    host.style.cssText = "position:fixed;left:-99999px;top:0;overflow:hidden;pointer-events:none;";
    document.body.appendChild(host);
    const root = window.ReactDOM.createRoot(host);
    try {
      root.render(
        React.createElement(VisualExportFrame, { width, height }, renderElement)
      );
      await new Promise(resolve => {
        requestAnimationFrame(() => requestAnimationFrame(resolve));
      });
      const node = host.firstElementChild;
      if (!node) throw new Error("Frame render produced no output");
      return await window.htmlToImage.toCanvas(node, {
        width,
        height,
        pixelRatio: 1,
        cacheBust: true,
        backgroundColor: backgroundColor || "#ffffff",
      });
    } finally {
      root.unmount();
      document.body.removeChild(host);
    }
  })();
  let timer;
  const timeout = new Promise((_, reject) => {
    timer = setTimeout(
      () => reject(new Error("Frame capture timed out — try PNG or a template with fewer frames.")),
      GIF_CAPTURE_TIMEOUT_MS
    );
  });
  try {
    return await Promise.race([work, timeout]);
  } finally {
    clearTimeout(timer);
  }
}

/* ---------------- GIF timing ---------------- */
function computeFrameDelay({ template, frameCount, speed = 1 }) {
  const count = frameCount || 1;
  let base = count > 40
    ? Math.max(50, Math.min(150, Math.round(6000 / count)))
    : (template?.gifDelay || 900);
  const s = Math.max(0.25, Math.min(4, Number(speed) || 1));
  return Math.max(40, Math.min(3000, Math.round(base / s)));
}

async function downloadTemplateAsGif({ template, data, filename, maxFrames, speed, onProgress }) {
  if (!window.GIF) throw new Error("GIF encoder not loaded");
  if (!template?.animated || !template.RenderFrame || !template.getFrameCount) {
    throw new Error("This template does not support GIF export");
  }
  const { count, capped, requested } = resolveGifExportFrames(template, data, maxFrames);
  if (count < 1) throw new Error("No animation frames to export");

  if (onProgress) onProgress({ phase: "start", frame: 0, total: count, capped, requested });

  const delay = computeFrameDelay({ template, frameCount: count, speed });
  const { width, height } = template;
  const bg = template.exportBackground || "#ffffff";
  const gif = createGifEncoder(width, height);

  for (let i = 0; i < count; i++) {
    if (onProgress) onProgress({ phase: "capture", frame: i + 1, total: count, capped, requested });
    const frameElement = React.createElement(template.RenderFrame, { data, frameIndex: i, frameCount: count });
    const wrappedFrame = React.createElement(
      VisualExportFrame,
      {
        width,
        height,
        byline: data?.byline,
        avatarDataUrl: data?.avatarDataUrl,
      },
      frameElement
    );
    const canvas = await captureReactFrame(
      wrappedFrame,
      width,
      height,
      bg
    );
    gif.addFrame(canvas, { delay, copy: true });
  }

  if (onProgress) onProgress({ phase: "encode", frame: count, total: count, capped, requested });

  return new Promise((resolve, reject) => {
    let settled = false;
    let encodeTimer;
    const fail = (err) => {
      if (settled) return;
      settled = true;
      clearTimeout(encodeTimer);
      reject(err instanceof Error ? err : new Error(String(err || "GIF encoding failed")));
    };
    const done = () => {
      if (settled) return;
      settled = true;
      clearTimeout(encodeTimer);
      resolve({ capped, requested, count });
    };

    encodeTimer = setTimeout(
      () => fail(new Error(
        `GIF encoding timed out after ${Math.round(GIF_ENCODE_TIMEOUT_MS / 1000)}s. ` +
        "On the web app, use a shorter template (under 24 frames) or download PNG instead."
      )),
      GIF_ENCODE_TIMEOUT_MS
    );

    gif.on("finished", blob => {
      try {
        if (!blob || !blob.size) {
          fail(new Error("GIF encoder produced an empty file. Try fewer frames or download PNG instead."));
          return;
        }
        // Keep the object URL alive long enough for browser download managers
        // to pick it up (immediate revoke can cancel downloads on some setups).
        const url = URL.createObjectURL(blob);
        const a = document.createElement("a");
        a.href = url;
        a.download = filename;
        a.rel = "noopener";
        document.body.appendChild(a);
        a.click();
        setTimeout(() => {
          try { document.body.removeChild(a); } catch {}
          try { URL.revokeObjectURL(url); } catch {}
        }, 60000);
        done();
      } catch (err) {
        // Fallback for environments where programmatic a[download] is blocked.
        try {
          const url = URL.createObjectURL(blob);
          window.open(url, "_blank", "noopener,noreferrer");
          setTimeout(() => {
            try { URL.revokeObjectURL(url); } catch {}
          }, 60000);
          done();
        } catch {
          fail(err);
        }
      }
    });
    gif.on("abort", () => fail(new Error("GIF encoding aborted")));
    if (typeof gif.on === "function") {
      gif.on("error", (e) => fail(new Error("GIF worker error — retry or use PNG. " + (e?.message || ""))));
      gif.on("progress", (p) => {
        if (onProgress) onProgress({ phase: "encode", frame: count, total: count, capped, requested, progress: p });
      });
    }
    try {
      gif.render();
    } catch (err) {
      fail(err);
    }
  });
}

Object.assign(window, {
  // constants
  STORAGE_KEY_TEXT, STORAGE_KEY_VISUAL, STYLES, TONES, PLATFORMS, PLATFORM_LIMITS, VISUAL_RESOURCE_PRESETS, VISUAL_LAYOUT_FAMILIES, APP_DEMO_VIDEO_URL, CREATOR_LINKS,
  isLocalDevHost, isLayoutExplorerEnabled,
  // utils
  styleLabel, toneLabel, postMetrics,
  getPlatformText, getPlatformTags, formatHashtags, formatPlatformTextWithTags, formatAllPlatforms,
  normalizePlatformPosts, normalizePlatformTags, getPostPreviewText, getPostMemorySnippet,
  loadFromStorage, saveToStorage,
  extractJson,
  EXPORT_WATERMARK_TEXT, VisualExportFrame,
  copyToClipboard, downloadNodeAsPng, downloadTemplateAsGif, captureReactFrame, computeFrameDelay,
  getGifExportFrameCap, resolveGifExportFrames,
  // prompts
  TEXT_SYSTEM_PROMPT, buildTextUserPrompt, generatePlatformPosts,
  // hooks
  useToast,
});
