/* ═══════════════════════════════════════════════════════════════════
   src/profile/app.jsx — Profile-page React entry-point (NAP 2 + 3)
   ═══════════════════════════════════════════════════════════════════

   Loaded by /profile.html (via Vercel rewrite /profile/:slug/:tab*).

   ARCHITECTURE:

     <App>
       ├── <ProfileHeader />        -- avatar + name + role
       ├── <ProfileTopNav />        -- [Feed] [About] tabs
       └── route-switch:
           ├── /feed:
           │   └── <FeedTab>        -- owns posts state, JWT, isAdmin
           │       ├── <ProfilePostForm />  -- IF isAdmin (NAP 3)
           │       └── <ProfileFeed />      -- presentational
           ├── /about:
           │   └── <AboutSection>     -- showrunner bio (i18n HU/EN/ES)
           └── (Connect tab DEFERRED to FRAME 07 inside /about)

   ROUTING:   client-side history.pushState. Unknown tab → 'feed'.
   ADMIN:     /api/admin/check-akos-mode (always 200, {isAdmin: bool}).
   JWT:       Supabase localStorage key, used for admin API calls.
   POSTING:   FeedTab lifts posts state so ProfilePostForm can prepend
              a new post to the feed on successful submit, without a
              re-fetch round-trip.
   ═══════════════════════════════════════════════════════════════════ */

/* global React, ReactDOM, marked, DOMPurify */

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

// ────────────────────────────────────────────────────────────────────
// i18n — UI-button labels (per STRATEGIC_PIVOT v1.0 §3 — content
// stays EN, only the platform chrome translates). Three locales:
// English (canonical), Hungarian (Akos's mother tongue + a major
// fan base), Spanish (Akos's Elche-resident + Spanish fan base).
// Add new keys here as the surface grows; missing translations
// fall back to the EN string.
// ────────────────────────────────────────────────────────────────────

const I18N_LANGS = ['en', 'es', 'hu'];
const I18N_DEFAULT = 'en';
// Shared platform language key (same as lang-pill.js + studio/pricing/etc.) so
// the chosen language persists across ALL pages, not just the profile.
const I18N_STORAGE_KEY = 'aip_lang';

const TRANSLATIONS = {
  en: {
    // Nav tabs
    feed: 'Feed',
    about: 'About',
    // Action buttons (post header)
    share: 'Share',
    link_copied: 'Link copied',
    edit: '✎ Edit',
    delete: '🗑️ Delete',
    // Edit-mode buttons
    save: 'Save',
    saving: 'Saving…',
    cancel: 'Cancel',
    remove_image_on_save: 'Remove image on save',
    // Form
    new_post: 'New post',
    publish: 'Publish',
    publishing: 'Publishing…',
    // Type labels (used in chip + dropdown)
    type_note: 'Note',
    type_announcement: 'Announcement',
    type_trailer: 'Trailer',
    type_art: 'Art',
    type_music: 'Music',
    type_video: 'Video',
    // Confirm dialogs
    delete_confirm: 'Delete this post? This cannot be undone.',
    // CTA card
    cta_eyebrow: 'AI Prismatik',
    cta_title: 'Living Fiction Ecosystem.',
    cta_body: 'Step inside while the studio is still building.',
    cta_fan: 'Become a Fan →',
    cta_founding: 'Founding Showrunner',
    // About content (showrunner bio)
    about_intro: "Over the years I've been an actor, a musician, a TV-show editor, a director and a producer; I've made commercials and music videos, and I've written for the stage. Now I'm bringing all of that experience together and becoming a Showrunner. Together with my co-founders I built the aiprismatik.com platform — a constantly evolving, entirely new media and content-creation ecosystem — and it's here that I'm beginning production on my first series. In this feed I document, step by step, what I'm doing, how, and why, as I build my own IP.",
    about_showrunner_zero: "I use the name Showrunner Zero because I'm the starting point: to the other showrunners — the creative leads who own and steer a series' entire production — I'm the one demonstrating how the studio system works, and with it this new, ultra-modern way of creating.",
    // Feed states
    feed_loading: 'Loading…',
    feed_error: "Couldn't load posts. Try refreshing the page.",
    feed_empty: 'No posts yet. The story begins soon.',
    load_more: 'Load more',
    loading_more: 'Loading…',
    load_more_error: "Couldn't load more. Try again.",
    // Profile-edit modal (NAP 4 — stáblista-név + credit_preference)
    profile_edit_button: '✎ Credits name',
    profile_edit_title: 'How you appear on the series credits',
    profile_edit_intro:
      'Series supporters (Super / Ultra / Mega Fan) have their names listed in the closing credits. Choose how you would like to appear.',
    profile_edit_realname_label: 'Your real name',
    profile_edit_realname_placeholder: 'e.g. Ákos Simonkovits',
    profile_edit_realname_hint: 'Optional. 1–100 characters.',
    profile_edit_credit_pref_label: 'Show on credits as:',
    profile_edit_credit_pref_realname: 'My real name',
    profile_edit_credit_pref_username: 'My username',
    profile_edit_credit_pref_none: 'Not set yet',
    profile_edit_preview_label: 'Preview:',
    profile_edit_privacy_warning:
      'The chosen name may appear publicly in the series credits.',
    profile_edit_saved: 'Saved',
    profile_edit_error: 'Could not save — please try again.',
  },
  hu: {
    feed: 'Hírfolyam',
    about: 'Rólam',
    share: 'Megosztás',
    link_copied: 'Link másolva',
    edit: '✎ Szerkesztés',
    delete: '🗑️ Törlés',
    save: 'Mentés',
    saving: 'Mentés…',
    cancel: 'Mégse',
    remove_image_on_save: 'Kép eltávolítása mentéskor',
    new_post: 'Új poszt',
    publish: 'Közzététel',
    publishing: 'Közzététel…',
    type_note: 'Jegyzet',
    type_announcement: 'Bejelentés',
    type_trailer: 'Trailer',
    type_art: 'Művészet',
    type_music: 'Zene',
    type_video: 'Videó',
    delete_confirm: 'Törlöd ezt a posztot? Ez nem visszavonható.',
    cta_eyebrow: 'AI Prismatik',
    cta_title: 'Élő Fikciós Ökoszisztéma.',
    cta_body: 'Lépj be, amíg a stúdió épül.',
    cta_fan: 'Légy Fan →',
    cta_founding: 'Alapító Showrunner',
    about_intro: 'Életem során voltam színész, zenész, tv-műsorok szerkesztője, rendező és producer; készítettem reklámfilmeket és videóklipeket, és írtam színdarabot is. Most az összes eddigi tapasztalatomat egyesítem, és Showrunner leszek. Alapítótársaimmal létrehoztuk az aiprismatik.com platformot — egy folyamatosan fejlődő, teljesen új média- és tartalomgyártói ökoszisztémát —, ahol most belevágok az első sorozat gyártásába. Ebben a feedben lépésről lépésre dokumentálom, mit, hogyan és miért teszek, hogy felépítsem a saját IP-met.',
    about_showrunner_zero: 'A Showrunner Zero nevet azért használom, mert én vagyok a kiindulópont: a többi Showrunnernek — annak a kreatív vezetőnek, aki egy sorozat teljes produkciójáért felel és irányítja azt — én mutatom meg, hogyan működik a stúdiórendszer, és vele ezt az újfajta, ultramodern alkotói módszert.',
    // Feed states
    feed_loading: 'Betöltés…',
    feed_error: 'Nem sikerült betölteni a posztokat. Frissítsd az oldalt.',
    feed_empty: 'Még nincs poszt. A történet hamarosan kezdődik.',
    load_more: 'Több betöltése',
    loading_more: 'Betöltés…',
    load_more_error: 'Nem sikerült többet betölteni. Próbáld újra.',
    // Profile-edit modal
    profile_edit_button: '✎ Stáblista-név',
    profile_edit_title: 'Hogyan jelenj meg a sorozat stáblistáján',
    profile_edit_intro:
      'A sorozat-támogatók (Super / Ultra / Mega Fan) neve megjelenik a záró stáblistán. Válaszd ki, milyen néven szeretnél megjelenni.',
    profile_edit_realname_label: 'A valódi neved',
    profile_edit_realname_placeholder: 'pl. Simonkovits Ákos',
    profile_edit_realname_hint: 'Opcionális. 1–100 karakter.',
    profile_edit_credit_pref_label: 'Stáblistán így jelenj meg:',
    profile_edit_credit_pref_realname: 'Valódi nevem',
    profile_edit_credit_pref_username: 'Felhasználónevem',
    profile_edit_credit_pref_none: 'Nincs beállítva',
    profile_edit_preview_label: 'Előnézet:',
    profile_edit_privacy_warning:
      'A kiválasztott név nyilvánosan megjelenhet a sorozat stáblistáján.',
    profile_edit_saved: 'Mentve',
    profile_edit_error: 'Nem sikerült menteni — próbáld újra.',
  },
  es: {
    feed: 'Feed',
    about: 'Acerca',
    share: 'Compartir',
    link_copied: 'Enlace copiado',
    edit: '✎ Editar',
    delete: '🗑️ Eliminar',
    save: 'Guardar',
    saving: 'Guardando…',
    cancel: 'Cancelar',
    remove_image_on_save: 'Eliminar imagen al guardar',
    new_post: 'Nueva publicación',
    publish: 'Publicar',
    publishing: 'Publicando…',
    type_note: 'Nota',
    type_announcement: 'Anuncio',
    type_trailer: 'Tráiler',
    type_art: 'Arte',
    type_music: 'Música',
    type_video: 'Vídeo',
    delete_confirm: '¿Eliminar esta publicación? Esta acción no se puede deshacer.',
    cta_eyebrow: 'AI Prismatik',
    cta_title: 'Ecosistema de Ficción Viva.',
    cta_body: 'Entra mientras el estudio se está construyendo.',
    cta_fan: 'Hazte Fan →',
    cta_founding: 'Showrunner Fundador',
    about_intro: 'A lo largo de mi vida he sido actor, músico, editor de programas de televisión, director y productor; he hecho anuncios y videoclips, y también he escrito para teatro. Ahora reúno toda esa experiencia y me convierto en Showrunner. Junto con mis socios fundadores he creado la plataforma aiprismatik.com —un ecosistema de medios y creación de contenido completamente nuevo y en constante evolución—, y es aquí donde comienzo la producción de mi primera serie. En este feed documento, paso a paso, qué hago, cómo y por qué, mientras construyo mi propia IP.',
    about_showrunner_zero: 'Uso el nombre Showrunner Zero porque soy el punto de partida: a los demás showrunners —los responsables creativos que dirigen y asumen toda la producción de una serie— soy yo quien les muestra cómo funciona el sistema de estudio, y con él esta nueva y ultramoderna forma de crear.',
    // Feed states
    feed_loading: 'Cargando…',
    feed_error: 'No se pudieron cargar las publicaciones. Actualiza la página.',
    feed_empty: 'Aún no hay publicaciones. La historia comienza pronto.',
    load_more: 'Cargar más',
    loading_more: 'Cargando…',
    load_more_error: 'No se pudo cargar más. Inténtalo de nuevo.',
    // Profile-edit modal
    profile_edit_button: '✎ Nombre en créditos',
    profile_edit_title: 'Cómo apareces en los créditos de la serie',
    profile_edit_intro:
      'Los nombres de los seguidores que apoyan la serie (Super / Ultra / Mega Fan) aparecen en los créditos finales. Elige cómo deseas aparecer.',
    profile_edit_realname_label: 'Tu nombre real',
    profile_edit_realname_placeholder: 'p. ej. Ákos Simonkovits',
    profile_edit_realname_hint: 'Opcional. 1–100 caracteres.',
    profile_edit_credit_pref_label: 'Aparecer en créditos como:',
    profile_edit_credit_pref_realname: 'Mi nombre real',
    profile_edit_credit_pref_username: 'Mi nombre de usuario',
    profile_edit_credit_pref_none: 'No establecido',
    profile_edit_preview_label: 'Vista previa:',
    profile_edit_privacy_warning:
      'El nombre seleccionado puede aparecer públicamente en los créditos de la serie.',
    profile_edit_saved: 'Guardado',
    profile_edit_error: 'No se pudo guardar — inténtalo de nuevo.',
  },
};

function detectInitialLanguage() {
  try {
    const stored = window.localStorage.getItem(I18N_STORAGE_KEY);
    if (stored && I18N_LANGS.includes(stored)) return stored;
  } catch { /* fall through to browser detect */ }
  try {
    const browser = (navigator.language || navigator.userLanguage || '').slice(0, 2).toLowerCase();
    if (I18N_LANGS.includes(browser)) return browser;
  } catch { /* fall through to default */ }
  return I18N_DEFAULT;
}

const I18nContext = React.createContext({
  lang: I18N_DEFAULT,
  setLang: () => {},
  t: (k) => k,
});

function I18nProvider({ children }) {
  const [lang, setLangState] = useState(detectInitialLanguage);

  const setLang = useCallback((next) => {
    if (!I18N_LANGS.includes(next)) return;
    setLangState(next);
    try { window.localStorage.setItem(I18N_STORAGE_KEY, next); } catch { /* non-blocking */ }
    try { document.documentElement.lang = next; } catch { /* non-blocking */ }
    // BLOCKER A (2026-05-29) — emit cross-tree event so the static
    // platform-nav <nav> at profile.html top (outside the React root)
    // can translate its Home/Studio/Handbook links. Profile.html's
    // inline script listens for `aip:langChanged`. Other pages don't
    // need this — they use `lang-pill.js`'s translateNav directly.
    try { window.dispatchEvent(new CustomEvent('aip:langChanged', { detail: next })); } catch { /* non-blocking */ }
  }, []);

  // Keep <html lang="…"> in sync on first paint so screen-readers /
  // assistive tech know which language we're in. Also fire the
  // langChanged event on initial mount so the static platform-nav
  // matches the detected-initial-language (HU/ES browser locale).
  useEffect(() => {
    try { document.documentElement.lang = lang; } catch { /* non-blocking */ }
    try { window.dispatchEvent(new CustomEvent('aip:langChanged', { detail: lang })); } catch { /* non-blocking */ }
  }, [lang]);

  const t = useCallback((key) => {
    const langTable = TRANSLATIONS[lang] || TRANSLATIONS[I18N_DEFAULT];
    return langTable[key] || TRANSLATIONS[I18N_DEFAULT][key] || key;
  }, [lang]);

  const value = useMemo(() => ({ lang, setLang, t }), [lang, setLang, t]);
  return <I18nContext.Provider value={value}>{children}</I18nContext.Provider>;
}

function useI18n() {
  return useContext(I18nContext);
}

// ────────────────────────────────────────────────────────────────────

// URL-driven slug (multi-tenant). The profile page is served for
// /profile/:slug/:tab* (vercel.json rewrite); we read the slug from the path
// so /profile/showrunner-zero AND /profile/founding-1 both resolve to the
// right showrunner. Defaults to showrunner-zero for safety. The name kept as
// SHOWRUNNER_SLUG so all existing usages stay byte-identical for Akos's page.
const SHOWRUNNER_SLUG = (function () {
  const m = window.location.pathname.match(/^\/profile\/([^/]+)/);
  return (m && m[1]) || 'showrunner-zero';
})();

// Admin authoring/edit UI (post-form, avatar/profile edit) shows ONLY on the
// admin's OWN profile slug — never when Akos views another showrunner's page.
const IS_OWN_ADMIN_PROFILE = (SHOWRUNNER_SLUG === 'showrunner-zero' || SHOWRUNNER_SLUG === 'showrunner.zero');

// Per-slug header identity chrome (name / role / avatar-initials). Curated for
// the launch showrunners — keeps Akos's header byte-identical and renders Ivan
// correctly even if his user_profiles row / avatar isn't set yet. avatar_url
// (uploaded) still comes from /profile-info and overrides the initials.
const PROFILE_IDENTITY = {
  'showrunner-zero': { name: 'Akos Simonkovits', role: 'Showrunner Zero',       initials: 'AS', uuid: 'c421ff75-1b5e-4c48-9dd4-b3a7d54c904e' },
  'showrunner.zero': { name: 'Akos Simonkovits', role: 'Showrunner Zero',       initials: 'AS', uuid: 'c421ff75-1b5e-4c48-9dd4-b3a7d54c904e' },
  'founding-1':      { name: 'Ivan Simonkovits', role: 'Founding Showrunner 1', initials: 'IS', uuid: 'de0c62e0-d193-4f06-bfba-31307e5af075' },
};
const IDENTITY = PROFILE_IDENTITY[SHOWRUNNER_SLUG] || PROFILE_IDENTITY['showrunner-zero'];

const VALID_TABS = ['feed', 'about'];
const DEFAULT_TAB = 'feed';
const SUPABASE_AUTH_STORAGE_KEY = 'sb-wrzzxxepdwgpwuqdbnyt-auth-token';

// Supabase project config — same publishable key + URL as auth.html /
// studio.html / welcome-fan.html. The key is the public-safe
// `sb_publishable_*` type (designed to ship to the browser); the
// service-role key never leaves Vercel serverless functions.
const SUPABASE_URL = 'https://wrzzxxepdwgpwuqdbnyt.supabase.co';
const SUPABASE_PUBLISHABLE_KEY = 'sb_publishable_Egsk8yxTgWNhCiJ_Vht16A_pXNjM9wF';

const POST_TYPES = [
  { value: 'note',         labelKey: 'type_note' },
  { value: 'announcement', labelKey: 'type_announcement' },
  { value: 'trailer',      labelKey: 'type_trailer' },
  { value: 'art',          labelKey: 'type_art' },
  { value: 'music',        labelKey: 'type_music' },
  { value: 'video',        labelKey: 'type_video' },
];
const POST_BODY_MAX = 10000;
const POST_TITLE_MAX = 200;

const IMAGE_MAX_DIM = 1920;
const IMAGE_WEBP_QUALITY = 0.85;

// ────────────────────────────────────────────────────────────────────
// URL parsing + client-side routing
// ────────────────────────────────────────────────────────────────────

function parseTabFromPath() {
  const m = window.location.pathname.match(/^\/profile\/[^/]+(?:\/([^/]+))?/);
  const t = m && m[1];
  return VALID_TABS.includes(t) ? t : DEFAULT_TAB;
}

function navigateTab(newTab) {
  if (!VALID_TABS.includes(newTab)) return;
  const path = `/profile/${SHOWRUNNER_SLUG}/${newTab}`;
  if (window.location.pathname !== path) {
    window.history.pushState({ tab: newTab }, '', path);
    window.dispatchEvent(new PopStateEvent('popstate'));
  }
}

// ────────────────────────────────────────────────────────────────────
// Supabase client + JWT (auto-refresh aware)
// ────────────────────────────────────────────────────────────────────
//
// The Supabase JS SDK is loaded via CDN in profile.html. We create
// the client lazily and only for the purpose of session management
// (autoRefreshToken: true means it silently refreshes the JWT in
// localStorage before the 1-hour TTL expires, as long as the page is
// open). Without this, a Showrunner posting from their laptop during
// the Madrid screenplay weekend (or any long session) would hit 401
// roughly once per hour and have to manually re-login each time.

let _supabaseClient = null;

function getSupabaseClient() {
  if (_supabaseClient) return _supabaseClient;
  if (typeof window === 'undefined' || !window.supabase || !window.supabase.createClient) {
    return null;
  }
  _supabaseClient = window.supabase.createClient(SUPABASE_URL, SUPABASE_PUBLISHABLE_KEY, {
    auth: {
      persistSession: true,
      autoRefreshToken: true,
      // OAuth callback handling lives on auth.html, not here.
      detectSessionInUrl: false,
      storageKey: SUPABASE_AUTH_STORAGE_KEY,
    },
  });
  return _supabaseClient;
}

// Get the current valid JWT. Uses the Supabase SDK when available
// (refresh-aware), falls back to direct localStorage read if the SDK
// failed to load. Returns null if no session.
async function getStoredJWT() {
  const sb = getSupabaseClient();
  if (sb) {
    try {
      const { data } = await sb.auth.getSession();
      if (data && data.session && data.session.access_token) {
        return data.session.access_token;
      }
    } catch {
      /* fall through to direct localStorage read */
    }
  }
  // Fallback path — SDK didn't load, but the legacy localStorage entry
  // may still hold a (non-refreshed) token. Useful for the seconds
  // before the SDK script-tag finishes loading.
  try {
    const raw = window.localStorage.getItem(SUPABASE_AUTH_STORAGE_KEY);
    if (!raw) return null;
    const parsed = JSON.parse(raw);
    return (parsed && parsed.access_token) || null;
  } catch {
    return null;
  }
}

// Current logged-in user id (local session) — used to hide the follow ACTION
// on your own profile.
async function getSessionUserId() {
  const sb = getSupabaseClient();
  if (sb) {
    try {
      const { data } = await sb.auth.getSession();
      if (data && data.session && data.session.user) return data.session.user.id;
    } catch { /* fall through */ }
  }
  try {
    const raw = window.localStorage.getItem(SUPABASE_AUTH_STORAGE_KEY);
    if (!raw) return null;
    const parsed = JSON.parse(raw);
    const sess = parsed.currentSession || parsed;
    return (sess.user && sess.user.id) || null;
  } catch { return null; }
}

// ────────────────────────────────────────────────────────────────────
// Markdown rendering (marked + DOMPurify, both via CDN in profile.html)
// ────────────────────────────────────────────────────────────────────

function renderMarkdown(text) {
  if (typeof text !== 'string' || text.length === 0) return '';
  if (typeof marked === 'undefined' || typeof DOMPurify === 'undefined') {
    return escapeHtml(text).replace(/\n/g, '<br>');
  }
  const rawHtml = marked.parse(text, { gfm: true, breaks: true });
  return DOMPurify.sanitize(rawHtml, { USE_PROFILES: { html: true } });
}

function escapeHtml(s) {
  return s
    .replace(/&/g, '&amp;')
    .replace(/</g, '&lt;')
    .replace(/>/g, '&gt;')
    .replace(/"/g, '&quot;')
    .replace(/'/g, '&#39;');
}

// ────────────────────────────────────────────────────────────────────
// Date formatting
// ────────────────────────────────────────────────────────────────────

// Formatted as "May 27, 2026 — 15:34" with Europe/Budapest timezone
// (handles CET/CEST automatically). Time uses HH:MM 24-hour format
// for a timecode-feel that fits the cinema-grade brand DNA. The
// separator is an em-dash (—) not a hyphen, matching the typographic
// style of the rest of the page.
function formatDate(iso) {
  if (!iso) return '';
  try {
    const d = new Date(iso);
    const datePart = d.toLocaleDateString('en-US', {
      year: 'numeric',
      month: 'long',
      day: 'numeric',
      timeZone: 'Europe/Budapest',
    });
    const timePart = d.toLocaleTimeString('en-GB', {
      hour: '2-digit',
      minute: '2-digit',
      hour12: false,
      timeZone: 'Europe/Budapest',
    });
    return `${datePart} — ${timePart}`;
  } catch {
    return iso;
  }
}

// ────────────────────────────────────────────────────────────────────
// WebP client-compression (canvas-based, zero library)
// ────────────────────────────────────────────────────────────────────

async function compressToWebP(file, options = {}) {
  const { maxDim = IMAGE_MAX_DIM, quality = IMAGE_WEBP_QUALITY } = options;
  const img = await createImageBitmap(file);
  let { width, height } = img;
  if (width > maxDim || height > maxDim) {
    const scale = Math.min(maxDim / width, maxDim / height);
    width = Math.round(width * scale);
    height = Math.round(height * scale);
  }
  const canvas = document.createElement('canvas');
  canvas.width = width;
  canvas.height = height;
  const ctx = canvas.getContext('2d');
  ctx.drawImage(img, 0, 0, width, height);
  const blob = await new Promise((resolve, reject) => {
    canvas.toBlob(
      (b) => (b ? resolve(b) : reject(new Error('canvas_toblob_failed'))),
      'image/webp',
      quality
    );
  });
  return { blob, width, height };
}

// ────────────────────────────────────────────────────────────────────
// API calls
// ────────────────────────────────────────────────────────────────────

async function fetchIsAdmin() {
  try {
    const token = await getStoredJWT();
    const headers = token ? { Authorization: `Bearer ${token}` } : {};
    const res = await fetch('/api/admin/check-akos-mode', { headers });
    if (!res.ok) return false;
    const data = await res.json();
    return !!(data && data.isAdmin);
  } catch {
    return false;
  }
}

async function fetchPosts(slug, page = 1) {
  const url = `/api/profile/${encodeURIComponent(slug)}/posts?page=${page}`;
  const res = await fetch(url);
  const data = await res.json().catch(() => ({}));
  if (!res.ok) {
    const err = new Error(data.error || `http_${res.status}`);
    err.detail = data.detail;
    throw err;
  }
  return {
    posts: Array.isArray(data.posts) ? data.posts : [],
    hasMore: !!(data.pagination && data.pagination.has_more),
    page: (data.pagination && data.pagination.page) || page,
  };
}

async function getSignedUploadUrl(jwt, filename, contentType) {
  const res = await fetch('/api/admin/upload-url', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      Authorization: `Bearer ${jwt}`,
    },
    body: JSON.stringify({ filename, content_type: contentType }),
  });
  const data = await res.json().catch(() => ({}));
  if (!res.ok) {
    const err = new Error(data.error || `http_${res.status}`);
    err.detail = data.detail;
    throw err;
  }
  return data;
}

async function uploadBlobToSignedUrl(signedUrl, blob, contentType) {
  const res = await fetch(signedUrl, {
    method: 'PUT',
    body: blob,
    headers: { 'Content-Type': contentType },
  });
  if (!res.ok) {
    throw new Error(`storage_put_failed_${res.status}`);
  }
}

async function createPost(jwt, payload) {
  const res = await fetch('/api/admin/posts', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      Authorization: `Bearer ${jwt}`,
    },
    body: JSON.stringify(payload),
  });
  const data = await res.json().catch(() => ({}));
  if (!res.ok) {
    const err = new Error(data.error || `http_${res.status}`);
    err.detail = data.detail;
    throw err;
  }
  return data.post;
}

// ════════════════════════════════════════════════════════════════════
// Components
// ════════════════════════════════════════════════════════════════════

// Follow ("ACTION") button for the profile being viewed. Same /api/follows +
// followable model as the hub feed. Hidden on your own profile.
function ProfileFollow({ ownerUuid, isOwn }) {
  const [following, setFollowing] = useState(false);
  const [busy, setBusy] = useState(false);
  const [meId, setMeId] = useState(null);

  useEffect(() => {
    let alive = true;
    (async () => {
      const id = await getSessionUserId();
      if (alive) setMeId(id);
      try {
        const jwt = await getStoredJWT();
        const res = await fetch(
          `/api/follows?followable_type=showrunner&followable_id=${ownerUuid}`,
          jwt ? { headers: { Authorization: `Bearer ${jwt}` } } : undefined
        );
        const data = await res.json().catch(() => ({}));
        if (alive && res.ok) setFollowing(!!data.following);
      } catch { /* leave default */ }
    })();
    return () => { alive = false; };
  }, [ownerUuid]);

  if (!ownerUuid || isOwn) return null;
  if (meId && meId === ownerUuid) return null;   // own profile (session-confirmed)

  async function toggle() {
    if (busy) return;
    const jwt = await getStoredJWT();
    if (!jwt) { window.location.href = '/auth'; return; }
    const was = following;
    setBusy(true);
    setFollowing(!was);
    try {
      const res = await fetch('/api/follows', {
        method: 'POST',
        headers: { Authorization: `Bearer ${jwt}`, 'Content-Type': 'application/json' },
        body: JSON.stringify({ followable_type: 'showrunner', followable_id: ownerUuid }),
      });
      if (res.status === 401) { window.location.href = '/auth'; return; }
      const data = await res.json().catch(() => ({}));
      if (!res.ok) throw new Error(data.detail || data.error || 'follow_failed');
      setFollowing(!!data.following);
    } catch { setFollowing(was); } finally { setBusy(false); }
  }

  return (
    <button type="button" className="profile-action" onClick={toggle} disabled={busy}
      style={{
        marginTop: 12, alignSelf: 'flex-start',
        fontFamily: "'JetBrains Mono', monospace", fontSize: 11, letterSpacing: '1px', fontWeight: 700,
        cursor: busy ? 'default' : 'pointer', padding: '9px 18px', borderRadius: 9,
        border: following ? '1px solid rgba(231,200,122,0.4)' : '0',
        color: following ? '#cbc6cf' : '#1a1206',
        background: following ? 'rgba(255,255,255,0.05)' : 'linear-gradient(135deg,#e7c87a,#d4a550)',
        opacity: busy ? 0.6 : 1,
      }}>
      {following ? 'ACTION ✓' : 'ACTION'}
    </button>
  );
}

function ProfileHeader({ isAdmin }) {
  const [avatarUrl, setAvatarUrl] = useState(null);
  // display_username + display_realname + credit_preference live here
  // alongside avatar_url because all four come from the same
  // /profile-info call. The modal needs them as starting values, and the
  // header itself may surface them in future (post-launch credits
  // preview chip etc).
  const [profileInfo, setProfileInfo] = useState({
    display_username:  null,
    display_realname:  null,
    credit_preference: null,
  });
  const [uploading, setUploading] = useState(false);
  const [error, setError] = useState(null);
  // Whether the profile-edit modal (NAP 4 stáblista-név) is open.
  const [editOpen, setEditOpen] = useState(false);
  const fileInputRef = useRef(null);

  // The `canEditProfile` boolean is what gates BOTH the avatar-edit
  // button (existing NAP 3) and the new stáblista-név button (NAP 4).
  // Today it is just `isAdmin` (Akos via /api/admin/check-akos-mode).
  // On the multifeed-day this becomes "current JWT user owns the profile
  // matching this slug" — derived from a slug→profile_id resolver +
  // JWT-user → profile-row lookup. The auth side (PATCH /api/profile/me)
  // is ALREADY multifeed-ready (requireProfileSelf in profile-auth.js);
  // only this gating boolean stays Akos-specific until then.
  const canEditProfile = isAdmin && IS_OWN_ADMIN_PROFILE;

  // Per-profile tab title (so Ivan's tab isn't "Akos Simonkovits — …").
  useEffect(() => { document.title = `${IDENTITY.name} — ${IDENTITY.role} · AI Prismatik`; }, []);

  // Fetch the public profile-info on mount to populate the avatar URL +
  // realname/credit_preference for the modal. Silent on failure — the
  // header still renders with the "AS" fallback.
  useEffect(() => {
    let cancelled = false;
    (async () => {
      try {
        const res = await fetch(`/api/profile/${SHOWRUNNER_SLUG}/profile-info`);
        if (!res.ok) return;
        const data = await res.json();
        if (cancelled) return;
        setAvatarUrl(data.avatar_url || null);
        setProfileInfo({
          display_username:  data.display_username  || null,
          display_realname:  data.display_realname  || null,
          credit_preference: data.credit_preference || null,
        });
      } catch {
        /* silent: header falls back to initials */
      }
    })();
    return () => {
      cancelled = true;
    };
  }, []);

  // Called by ProfileEditModal after a successful PATCH. Pulls the
  // updated row into local state so the next modal-open shows the new
  // values without a refetch.
  const handleProfileSaved = useCallback((updated) => {
    setProfileInfo((prev) => ({
      ...prev,
      display_realname:  updated.display_realname  ?? null,
      credit_preference: updated.credit_preference ?? null,
    }));
  }, []);

  function handleEditClick() {
    if (uploading) return;
    if (fileInputRef.current) fileInputRef.current.click();
  }

  async function handleFileChange(e) {
    const file = e.target.files && e.target.files[0];
    if (!file) return;
    if (!file.type.startsWith('image/')) {
      setError('Image files only (JPG / PNG / WebP).');
      return;
    }
    if (file.size > 20 * 1024 * 1024) {
      setError(`Image must be ≤ 20 MB (got ${(file.size / 1024 / 1024).toFixed(1)} MB).`);
      return;
    }
    setError(null);
    setUploading(true);

    try {
      const jwt = await getStoredJWT();
      if (!jwt) {
        throw new Error('No session — log in on www.aiprismatik.com first.');
      }

      // 1. Compress with avatar-sized cap (smaller than post images).
      const { blob } = await compressToWebP(file, { maxDim: 512, quality: 0.88 });

      // 2. Get a signed upload URL from the storage bucket.
      const filename = `avatar-${Date.now()}.webp`;
      const signed = await getSignedUploadUrl(jwt, filename, 'image/webp');

      // 3. PUT the WebP directly to Supabase Storage.
      await uploadBlobToSignedUrl(signed.upload_url, blob, 'image/webp');

      // 4. Persist the public URL on the user_profiles row.
      const setRes = await fetch('/api/admin/profile/avatar', {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
          Authorization: `Bearer ${jwt}`,
        },
        body: JSON.stringify({ avatar_url: signed.public_url }),
      });
      const setData = await setRes.json().catch(() => ({}));
      if (!setRes.ok) {
        throw new Error(setData.detail || setData.error || `http_${setRes.status}`);
      }

      setAvatarUrl(setData.avatar_url);
    } catch (err) {
      setError(err.message || 'Avatar upload failed.');
    } finally {
      setUploading(false);
      if (fileInputRef.current) fileInputRef.current.value = '';
    }
  }

  const avatarClasses = [
    'profile-avatar',
    avatarUrl ? 'profile-avatar-image' : '',
    uploading ? 'is-uploading' : '',
  ]
    .filter(Boolean)
    .join(' ');

  return (
    <header className="profile-header">
      <div className={avatarClasses} role="img" aria-label={IDENTITY.name}>
        {avatarUrl ? (
          <img src={avatarUrl} alt="" loading="lazy" />
        ) : (
          <span aria-hidden="true">{IDENTITY.initials}</span>
        )}
        {canEditProfile && (
          <>
            <button
              type="button"
              className="profile-avatar-edit"
              onClick={handleEditClick}
              disabled={uploading}
              aria-label="Edit avatar"
              title="Edit avatar"
            >
              {uploading ? '…' : '✎'}
            </button>
            <input
              ref={fileInputRef}
              type="file"
              accept="image/jpeg,image/png,image/webp"
              onChange={handleFileChange}
              hidden
            />
          </>
        )}
      </div>
      <div className="profile-identity">
        <h1 className="profile-name">{IDENTITY.name}</h1>
        <p className="profile-role">{IDENTITY.role}</p>
        {/* ACTION (follow) — for visitors who land directly on the profile.
            Hidden on your own profile. */}
        <ProfileFollow ownerUuid={IDENTITY.uuid} isOwn={isAdmin && IS_OWN_ADMIN_PROFILE} />
        {canEditProfile && (
          <ProfileEditButton onClick={() => setEditOpen(true)} />
        )}
        {error && (
          <p className="profile-avatar-error" role="alert">
            {error}
          </p>
        )}
      </div>
      {canEditProfile && editOpen && (
        <ProfileEditModal
          initial={profileInfo}
          onClose={() => setEditOpen(false)}
          onSaved={handleProfileSaved}
        />
      )}
    </header>
  );
}

// Small inline button under the role-line — opens the stáblista-név modal.
// Separated for readability; the modal itself is heavy enough to warrant
// its own component.
function ProfileEditButton({ onClick }) {
  const { t } = useI18n();
  return (
    <button
      type="button"
      className="profile-edit-button"
      onClick={onClick}
      title={t('profile_edit_title')}
    >
      {t('profile_edit_button')}
    </button>
  );
}

// Profile-edit modal (NAP 4 — brief §4.8 stáblista-név).
//
// JWT-only PATCH to /api/profile/me. Generic owner-only — the backend
// (requireProfileSelf) authorizes by JWT email → user_profiles row, so
// this UI works identically the day a Founding Showrunner uses it on
// their own profile-page.
//
// Fields:
//   - display_realname  (free-text, 0..100 chars, optional)
//   - credit_preference (radio: 'realname' | 'username' | null)
//
// UX:
//   - Live preview of "how you'll appear on credits" so the choice is
//     concrete, not abstract.
//   - Privacy micro-warning above the save button (per brief §4.8: "a
//     mezőnél: 'nyilvánosan megjelenhet'").
//   - Save → PATCH, then onSaved() + onClose(). Save errors stay open.
function ProfileEditModal({ initial, onClose, onSaved }) {
  const { t } = useI18n();
  const [realname, setRealname] = useState(initial.display_realname || '');
  const [creditPref, setCreditPref] = useState(initial.credit_preference || '');
  const [saving, setSaving] = useState(false);
  const [errorMsg, setErrorMsg] = useState(null);

  // Close-on-Escape — convenience for keyboard users.
  useEffect(() => {
    function onKey(e) { if (e.key === 'Escape' && !saving) onClose(); }
    window.addEventListener('keydown', onKey);
    return () => window.removeEventListener('keydown', onKey);
  }, [onClose, saving]);

  // Live preview text mirrors how the credits-listing will render the
  // chosen name. Falls back to a "not set" hint when the user hasn't
  // picked a preference yet.
  const previewName = (() => {
    if (creditPref === 'realname') return (realname || '').trim() || t('profile_edit_credit_pref_none');
    if (creditPref === 'username') return initial.display_username || t('profile_edit_credit_pref_none');
    return t('profile_edit_credit_pref_none');
  })();

  async function handleSave(e) {
    e.preventDefault();
    if (saving) return;
    setErrorMsg(null);
    setSaving(true);

    try {
      const jwt = await getStoredJWT();
      if (!jwt) throw new Error('no_session');

      // Trim + normalize. Empty string → null (clears the field).
      const trimmed = (realname || '').trim();
      const body = {
        display_realname:  trimmed === '' ? null : trimmed,
        credit_preference: creditPref === '' ? null : creditPref,
      };

      const res = await fetch('/api/profile/me', {
        method: 'PATCH',
        headers: {
          'Content-Type': 'application/json',
          Authorization: `Bearer ${jwt}`,
        },
        body: JSON.stringify(body),
      });
      const data = await res.json().catch(() => ({}));
      if (!res.ok) throw new Error(data.detail || data.error || `http_${res.status}`);

      onSaved(data.profile || body);
      onClose();
    } catch (err) {
      setErrorMsg(err.message === 'no_session'
        ? 'Please refresh the page and log in again.'
        : t('profile_edit_error'));
      setSaving(false);
    }
  }

  return (
    <div className="profile-edit-overlay" role="dialog" aria-modal="true" aria-labelledby="profile-edit-title">
      <div className="profile-edit-modal" onClick={(e) => e.stopPropagation()}>
        <form onSubmit={handleSave}>
          <h2 id="profile-edit-title" className="profile-edit-title">{t('profile_edit_title')}</h2>
          <p className="profile-edit-intro">{t('profile_edit_intro')}</p>

          <label className="profile-edit-field">
            <span className="profile-edit-field-label">{t('profile_edit_realname_label')}</span>
            <input
              type="text"
              maxLength={100}
              value={realname}
              onChange={(e) => setRealname(e.target.value)}
              placeholder={t('profile_edit_realname_placeholder')}
              disabled={saving}
            />
            <span className="profile-edit-field-hint">{t('profile_edit_realname_hint')}</span>
          </label>

          <fieldset className="profile-edit-radio-group" disabled={saving}>
            <legend className="profile-edit-field-label">{t('profile_edit_credit_pref_label')}</legend>
            <label className="profile-edit-radio">
              <input
                type="radio"
                name="credit_preference"
                value="realname"
                checked={creditPref === 'realname'}
                onChange={() => setCreditPref('realname')}
              />
              <span>{t('profile_edit_credit_pref_realname')}</span>
            </label>
            <label className="profile-edit-radio">
              <input
                type="radio"
                name="credit_preference"
                value="username"
                checked={creditPref === 'username'}
                onChange={() => setCreditPref('username')}
              />
              <span>
                {t('profile_edit_credit_pref_username')}
                {initial.display_username ? ` (@${initial.display_username})` : ''}
              </span>
            </label>
          </fieldset>

          <div className="profile-edit-preview">
            <span className="profile-edit-preview-label">{t('profile_edit_preview_label')}</span>
            <strong className="profile-edit-preview-name">{previewName}</strong>
          </div>

          <p className="profile-edit-privacy">{t('profile_edit_privacy_warning')}</p>

          {errorMsg && (
            <p className="profile-edit-error" role="alert">{errorMsg}</p>
          )}

          <div className="profile-edit-actions">
            <button
              type="button"
              className="profile-edit-cancel"
              onClick={onClose}
              disabled={saving}
            >
              {t('cancel')}
            </button>
            <button
              type="submit"
              className="profile-edit-save"
              disabled={saving}
            >
              {saving ? t('saving') : t('save')}
            </button>
          </div>
        </form>
      </div>
    </div>
  );
}

function ProfileTopNav({ tab, onChange }) {
  const { lang, setLang, t } = useI18n();
  return (
    <nav className="profile-top-nav" aria-label="Profile sections">
      <div className="profile-top-nav-inner">
        <div className="profile-top-nav-tabs">
          {VALID_TABS.map((tabKey) => (
            <button
              key={tabKey}
              type="button"
              className={`profile-tab ${tab === tabKey ? 'is-active' : ''}`}
              onClick={() => onChange(tabKey)}
              aria-current={tab === tabKey ? 'page' : undefined}
            >
              {t(tabKey)}
            </button>
          ))}
        </div>
        <div className="profile-top-nav-langs" role="group" aria-label="Language">
          {I18N_LANGS.map((code) => (
            <button
              key={code}
              type="button"
              className={`profile-lang ${lang === code ? 'is-active' : ''}`}
              onClick={() => setLang(code)}
              aria-pressed={lang === code}
              aria-label={`Switch language to ${code.toUpperCase()}`}
            >
              {code.toUpperCase()}
            </button>
          ))}
        </div>
      </div>
    </nav>
  );
}

function ProfilePost({ post, isAdmin, onDeleted, onUpdated }) {
  const { t } = useI18n();
  const bodyHtml = useMemo(() => renderMarkdown(post.body || ''), [post.body]);
  // Translate the type chip text via the i18n table so HU/ES viewers
  // see "JEGYZET" / "NOTA" instead of "NOTE". Falls back to the raw
  // enum value if no translation exists.
  const typeTranslated = post.type ? t(`type_${post.type}`) : '';
  const typeLabel = typeTranslated ? typeTranslated.toUpperCase() : '';
  const [deleting, setDeleting] = useState(false);
  // Initial share-button label is the translated "Share". Switches to
  // "Link copied" for 2s after a clipboard-fallback copy succeeds.
  const [shareLabel, setShareLabel] = useState(() => t('share'));

  // Re-sync share-label when the language changes (only when in its
  // default state — don't overwrite an active "Link copied" toast).
  useEffect(() => {
    setShareLabel((prev) => (prev === t('link_copied') ? prev : t('share')));
  }, [t]);

  // Clapperboard reaction ("csapó") state. Source of truth for the
  // count is post.likes_count (from the posts API). For "did I like
  // this", we cache an MVP belief in localStorage keyed by post.id —
  // post-launch hét-1 will replace this with is_liked_by_me from the
  // server (which requires JWT-aware GET endpoints).
  const [liked, setLiked] = useState(() => {
    try {
      return window.localStorage.getItem(`liked:${post.id}`) === '1';
    } catch {
      return false;
    }
  });
  const [likesCount, setLikesCount] = useState(
    typeof post.likes_count === 'number' ? post.likes_count : 0
  );
  const [clapping, setClapping] = useState(false);

  async function handleClap() {
    if (clapping) return;
    const jwt = await getStoredJWT();
    if (!jwt) {
      // Anonymous viewer — bounce to /auth so they can sign up + come
      // back. We don't preserve a deep return-path (post-launch nice-
      // to-have) — the value-add of one-click react isn't worth a
      // bespoke OAuth-redirect dance pre-launch.
      window.location.href = '/auth';
      return;
    }

    // Optimistic toggle — UI reflects the new state immediately,
    // server reconciles on response (or we revert on error).
    setClapping(true);
    const wasLiked = liked;
    const previousCount = likesCount;
    const nextLiked = !wasLiked;
    setLiked(nextLiked);
    setLikesCount((c) => Math.max(0, c + (nextLiked ? 1 : -1)));
    try {
      window.localStorage.setItem(`liked:${post.id}`, nextLiked ? '1' : '0');
    } catch { /* localStorage disabled — non-blocking */ }

    try {
      const res = await fetch(`/api/posts/${post.id}/like`, {
        method: 'POST',
        headers: { Authorization: `Bearer ${jwt}` },
      });
      const data = await res.json().catch(() => ({}));
      if (!res.ok) {
        throw new Error(data.detail || data.error || `http_${res.status}`);
      }
      // Reconcile against the server's truth.
      setLiked(!!data.liked);
      if (typeof data.likes_count === 'number') {
        setLikesCount(data.likes_count);
      }
      try {
        window.localStorage.setItem(`liked:${post.id}`, data.liked ? '1' : '0');
      } catch { /* non-blocking */ }
    } catch (_err) {
      // Revert optimistic update — server didn't accept the toggle.
      setLiked(wasLiked);
      setLikesCount(previousCount);
      try {
        window.localStorage.setItem(`liked:${post.id}`, wasLiked ? '1' : '0');
      } catch { /* non-blocking */ }
    } finally {
      setClapping(false);
    }
  }

  // Edit state — only used when isAdmin === true. The user clicks the
  // pencil button, the post body swaps to an inline form pre-populated
  // with the current values; Save calls PATCH /api/admin/posts/<id>;
  // Cancel discards local changes.
  const [editing, setEditing] = useState(false);
  const [editType, setEditType] = useState(post.type || 'note');
  const [editTitle, setEditTitle] = useState(post.title || '');
  const [editBody, setEditBody] = useState(post.body || '');
  const [removeImage, setRemoveImage] = useState(false);
  const [saving, setSaving] = useState(false);
  const [editError, setEditError] = useState(null);

  function handleStartEdit() {
    setEditType(post.type || 'note');
    setEditTitle(post.title || '');
    setEditBody(post.body || '');
    setRemoveImage(false);
    setEditError(null);
    setEditing(true);
  }

  function handleCancelEdit() {
    setEditing(false);
    setEditError(null);
  }

  async function handleSaveEdit(e) {
    e && e.preventDefault && e.preventDefault();
    if (saving) return;

    // Build a partial-update body — only include fields that actually
    // changed, so the PATCH validator gets a clean payload and the
    // updated_at timestamp doesn't churn on no-op saves.
    const updates = {};
    const trimmedTitle = editTitle.trim();
    const trimmedBody = editBody.trim();

    if (trimmedBody.length === 0) {
      setEditError('Body cannot be empty.');
      return;
    }
    if (trimmedBody.length > POST_BODY_MAX) {
      setEditError(`Body must be ≤ ${POST_BODY_MAX} characters (got ${trimmedBody.length}).`);
      return;
    }
    if (trimmedTitle.length > POST_TITLE_MAX) {
      setEditError(`Title must be ≤ ${POST_TITLE_MAX} characters.`);
      return;
    }

    if (editType !== post.type) updates.type = editType;
    if (trimmedTitle !== (post.title || '')) {
      updates.title = trimmedTitle || null;
    }
    if (trimmedBody !== (post.body || '')) updates.body = trimmedBody;
    if (removeImage && post.media_url) {
      updates.media_url = null;
      updates.media_type = null;
      updates.media_width = null;
      updates.media_height = null;
    }

    if (Object.keys(updates).length === 0) {
      // Nothing to save → close the editor without round-tripping.
      setEditing(false);
      return;
    }

    setSaving(true);
    setEditError(null);
    try {
      const jwt = await getStoredJWT();
      if (!jwt) throw new Error('No session — log in on www.aiprismatik.com first.');
      const res = await fetch(`/api/admin/posts/${post.id}`, {
        method: 'PATCH',
        headers: {
          'Content-Type': 'application/json',
          Authorization: `Bearer ${jwt}`,
        },
        body: JSON.stringify(updates),
      });
      const data = await res.json().catch(() => ({}));
      if (!res.ok) {
        throw new Error(data.detail || data.error || `http_${res.status}`);
      }
      onUpdated && onUpdated(data.post);
      setEditing(false);
    } catch (err) {
      setEditError(err.message || 'Save failed.');
    } finally {
      setSaving(false);
    }
  }

  async function handleShare() {
    const url = `${window.location.origin}/profile/${SHOWRUNNER_SLUG}/feed#post-${post.id}`;
    const shareTitle = post.title || 'Akos Simonkovits — Showrunner Zero';
    // Use body's first ~200 chars as text (stripped of markdown markers
    // since most share-targets render plain).
    const plainBody = (post.body || '')
      .replace(/[*_`#>]/g, '')
      .replace(/\s+/g, ' ')
      .trim()
      .slice(0, 200);
    const shareData = { title: shareTitle, text: plainBody, url };

    try {
      // Native Web Share API (mobile + recent Chrome on desktop): opens
      // the OS share-sheet, which already lists FB / WhatsApp / X /
      // Mail / etc. with proper deep-links. Zero scope for us.
      if (navigator.share && typeof navigator.share === 'function') {
        await navigator.share(shareData);
        return;
      }
      // Clipboard fallback (older desktop browsers): copy the URL and
      // show a transient "Link copied" affordance on the button itself.
      if (navigator.clipboard && navigator.clipboard.writeText) {
        await navigator.clipboard.writeText(url);
        setShareLabel(t('link_copied'));
        setTimeout(() => setShareLabel(t('share')), 2000);
        return;
      }
      // Last resort: prompt the user to copy manually.
      window.prompt('Copy this URL:', url);
    } catch (_err) {
      // User canceled the share dialog (or copy was denied). No-op —
      // they'll click again if they want.
    }
  }

  async function handleDelete() {
    if (deleting) return;
    if (!window.confirm(t('delete_confirm'))) return;
    setDeleting(true);
    try {
      const jwt = await getStoredJWT();
      if (!jwt) throw new Error('No session — log in on www.aiprismatik.com first.');
      const res = await fetch(`/api/admin/posts/${post.id}`, {
        method: 'DELETE',
        headers: { Authorization: `Bearer ${jwt}` },
      });
      if (!res.ok) {
        const data = await res.json().catch(() => ({}));
        throw new Error(data.detail || data.error || `http_${res.status}`);
      }
      onDeleted && onDeleted(post.id);
      // Parent removes us from the list → component unmounts → no setDeleting(false) needed.
    } catch (err) {
      window.alert(`Couldn't delete: ${err.message || 'unknown error'}`);
      setDeleting(false);
    }
  }

  return (
    <article
      id={`post-${post.id}`}
      className={`profile-post${editing ? ' is-editing' : ''}`}
      aria-labelledby={`post-${post.id}-title`}
    >
      <header className="profile-post-header">
        {editing ? (
          <select
            className="profile-post-edit-type"
            value={editType}
            onChange={(e) => setEditType(e.target.value)}
            disabled={saving}
            aria-label="Post type"
          >
            {POST_TYPES.map((opt) => (
              <option key={opt.value} value={opt.value}>{t(opt.labelKey)}</option>
            ))}
          </select>
        ) : (
          typeLabel && (
            <span className={`profile-post-type profile-post-type-${post.type}`}>{typeLabel}</span>
          )
        )}
        <time className="profile-post-date" dateTime={post.published_at}>
          {formatDate(post.published_at)}
        </time>
        {!editing && (
          <button
            type="button"
            className="profile-post-share"
            onClick={handleShare}
            aria-label="Share post"
          >
            {shareLabel}
          </button>
        )}
        {isAdmin && !editing && (
          <>
            <button
              type="button"
              className="profile-post-edit"
              onClick={handleStartEdit}
              aria-label="Edit post"
              title="Edit post"
            >
              {t('edit')}
            </button>
            <button
              type="button"
              className="profile-post-delete"
              onClick={handleDelete}
              disabled={deleting}
              aria-label="Delete post"
            >
              {deleting ? '…' : t('delete')}
            </button>
          </>
        )}
        {editing && (
          <>
            <button
              type="button"
              className="profile-post-edit-cancel"
              onClick={handleCancelEdit}
              disabled={saving}
            >
              {t('cancel')}
            </button>
            <button
              type="button"
              className="profile-post-edit-save"
              onClick={handleSaveEdit}
              disabled={saving}
            >
              {saving ? t('saving') : t('save')}
            </button>
          </>
        )}
      </header>

      {editing ? (
        <form className="profile-post-edit-form" onSubmit={handleSaveEdit}>
          <input
            type="text"
            className="profile-post-edit-title"
            placeholder="Title (optional)"
            value={editTitle}
            onChange={(e) => setEditTitle(e.target.value)}
            disabled={saving}
            maxLength={POST_TITLE_MAX + 100}
          />
          <textarea
            className="profile-post-edit-body"
            value={editBody}
            onChange={(e) => setEditBody(e.target.value)}
            disabled={saving}
            rows={Math.max(6, Math.min(20, editBody.split('\n').length + 1))}
          />
          {post.media_url && post.media_type === 'image' && (
            <label className="profile-post-edit-remove-image">
              <input
                type="checkbox"
                checked={removeImage}
                onChange={(e) => setRemoveImage(e.target.checked)}
                disabled={saving}
              />
              <span>{t('remove_image_on_save')}</span>
            </label>
          )}
          {editError && (
            <div className="profile-post-form-error" role="alert">
              {editError}
            </div>
          )}
        </form>
      ) : (
        <>
          {post.title && (
            <h2 id={`post-${post.id}-title`} className="profile-post-title">
              {post.title}
            </h2>
          )}

          {post.media_url && post.media_type === 'image' && (
            <figure className="profile-post-media">
              <img
                src={post.media_url}
                alt=""
                loading="lazy"
                width={post.media_width || undefined}
                height={post.media_height || undefined}
              />
            </figure>
          )}

          <div
            className="profile-post-body"
            dangerouslySetInnerHTML={{ __html: bodyHtml }}
          />

          <footer className="profile-post-actions">
            <button
              type="button"
              className={`profile-post-clap${liked ? ' is-liked' : ''}`}
              onClick={handleClap}
              disabled={clapping}
              aria-pressed={liked}
              aria-label={liked ? 'Remove clap reaction' : 'Clap for this post'}
              title={liked ? 'You clapped' : 'Clap'}
            >
              {/* Clapperboard icon — slate body + 3 angled clap-stick
                  stripes. Brand-DNS: NOT a heart. Filmes-mutatás. */}
              <svg
                className="profile-post-clap-icon"
                width="18"
                height="18"
                viewBox="0 0 24 24"
                fill="none"
                stroke="currentColor"
                strokeWidth="1.5"
                strokeLinecap="round"
                strokeLinejoin="round"
                aria-hidden="true"
              >
                <rect x="3" y="9" width="18" height="11" rx="0.5" />
                <line x1="3" y1="9" x2="21" y2="9" />
                <line x1="6" y1="9" x2="9" y2="4" />
                <line x1="11" y1="9" x2="14" y2="4" />
                <line x1="16" y1="9" x2="19" y2="4" />
              </svg>
              <span className="profile-post-clap-count">{likesCount}</span>
            </button>
          </footer>
        </>
      )}
    </article>
  );
}

function ProfileFeed({ posts, loading, error, hasMore, onLoadMore, isAdmin, onDeleted, onUpdated }) {
  const { t } = useI18n();
  if (loading && posts.length === 0) {
    return <div className="profile-feed-state">{t('feed_loading')}</div>;
  }
  if (error && posts.length === 0) {
    return (
      <div className="profile-feed-state profile-feed-state-error">
        {t('feed_error')}
      </div>
    );
  }
  if (posts.length === 0) {
    return <div className="profile-feed-state">{t('feed_empty')}</div>;
  }

  return (
    <div className="profile-feed">
      {posts.map((post) => (
        <ProfilePost
          key={post.id}
          post={post}
          isAdmin={isAdmin}
          onDeleted={onDeleted}
          onUpdated={onUpdated}
        />
      ))}
      {hasMore && (
        <button
          type="button"
          className="profile-feed-load-more"
          onClick={onLoadMore}
          disabled={loading}
        >
          {loading ? t('loading_more') : t('load_more')}
        </button>
      )}
      {error && posts.length > 0 && (
        <div className="profile-feed-state profile-feed-state-error">
          {t('load_more_error')}
        </div>
      )}
    </div>
  );
}

function ProfilePostForm({ onPosted }) {
  const { t } = useI18n();
  const [type, setType] = useState('note');
  const [title, setTitle] = useState('');
  const [body, setBody] = useState('');
  const [imageFile, setImageFile] = useState(null);
  const [imagePreview, setImagePreview] = useState(null);
  const [imageMeta, setImageMeta] = useState(null); // { originalSize, compressedSize, width, height }
  const [submitting, setSubmitting] = useState(false);
  const [error, setError] = useState(null);
  const [stage, setStage] = useState(null); // 'compressing' | 'uploading' | 'posting' | null
  const fileInputRef = useRef(null);

  // Revoke ObjectURL on unmount or replacement to avoid memory leaks.
  useEffect(() => {
    return () => {
      if (imagePreview) URL.revokeObjectURL(imagePreview);
    };
  }, [imagePreview]);

  function handleFileChange(e) {
    const file = e.target.files && e.target.files[0];
    if (!file) return;
    if (!file.type.startsWith('image/')) {
      setError('Image files only (JPG / PNG / WebP).');
      return;
    }
    if (file.size > 20 * 1024 * 1024) {
      setError(`Image must be ≤ 20 MB (got ${(file.size / 1024 / 1024).toFixed(1)} MB).`);
      return;
    }
    setError(null);
    if (imagePreview) URL.revokeObjectURL(imagePreview);
    setImageFile(file);
    setImagePreview(URL.createObjectURL(file));
    setImageMeta({ originalSize: file.size, compressedSize: null, width: null, height: null });
  }

  function clearImage() {
    if (imagePreview) URL.revokeObjectURL(imagePreview);
    setImageFile(null);
    setImagePreview(null);
    setImageMeta(null);
    if (fileInputRef.current) fileInputRef.current.value = '';
  }

  async function handleSubmit(e) {
    e.preventDefault();
    if (submitting) return;

    const trimmedBody = body.trim();
    if (!trimmedBody) {
      setError('Body is required.');
      return;
    }
    if (trimmedBody.length > POST_BODY_MAX) {
      setError(`Body must be ≤ ${POST_BODY_MAX} characters (got ${trimmedBody.length}).`);
      return;
    }

    const jwt = await getStoredJWT();
    if (!jwt) {
      setError(
        'No active session. Open https://www.aiprismatik.com/auth and sign in with Google, then come back to this tab and try again.'
      );
      return;
    }

    setSubmitting(true);
    setError(null);

    try {
      let media = null;

      if (imageFile) {
        setStage('compressing');
        const { blob, width, height } = await compressToWebP(imageFile);
        setImageMeta((prev) => ({
          ...(prev || {}),
          compressedSize: blob.size,
          width,
          height,
        }));

        setStage('uploading');
        const filename = `bts-${Date.now()}.webp`;
        const signed = await getSignedUploadUrl(jwt, filename, 'image/webp');
        await uploadBlobToSignedUrl(signed.upload_url, blob, 'image/webp');

        media = {
          media_url: signed.public_url,
          media_type: 'image',
          media_width: width,
          media_height: height,
        };
      }

      setStage('posting');
      const newPost = await createPost(jwt, {
        type,
        ...(title.trim() ? { title: title.trim() } : {}),
        body: trimmedBody,
        ...(media || {}),
      });

      // Reset form
      setType('note');
      setTitle('');
      setBody('');
      clearImage();

      onPosted && onPosted(newPost);
    } catch (err) {
      const detail = err && (err.detail || err.message);
      setError(detail || 'Unknown error.');
    } finally {
      setSubmitting(false);
      setStage(null);
    }
  }

  const bodyCount = body.length;
  const titleCount = title.length;
  const bodyOver = bodyCount > POST_BODY_MAX;
  const titleOver = titleCount > POST_TITLE_MAX;
  const canSubmit = !submitting && body.trim().length > 0 && !bodyOver && !titleOver;

  const stageLabel = stage === 'compressing'
    ? 'Compressing image…'
    : stage === 'uploading'
    ? 'Uploading image…'
    : stage === 'posting'
    ? 'Publishing post…'
    : null;

  return (
    <form className="profile-post-form" onSubmit={handleSubmit} aria-labelledby="post-form-title">
      <h2 id="post-form-title" className="profile-post-form-title">{t('new_post')}</h2>

      <div className="profile-post-form-row">
        <label className="profile-post-form-label" htmlFor="post-type">Type</label>
        <select
          id="post-type"
          className="profile-post-form-select"
          value={type}
          onChange={(e) => setType(e.target.value)}
          disabled={submitting}
        >
          {POST_TYPES.map((opt) => (
            <option key={opt.value} value={opt.value}>{t(opt.labelKey)}</option>
          ))}
        </select>
      </div>

      <div className="profile-post-form-row">
        <label className="profile-post-form-label" htmlFor="post-title">
          Title <span className="profile-post-form-hint">optional</span>
        </label>
        <input
          id="post-title"
          type="text"
          className={`profile-post-form-input ${titleOver ? 'is-over' : ''}`}
          value={title}
          onChange={(e) => setTitle(e.target.value)}
          disabled={submitting}
          maxLength={POST_TITLE_MAX + 100} /* allow over so we can show counter */
          placeholder="Leave blank if the post speaks for itself"
        />
        <span className={`profile-post-form-counter ${titleOver ? 'is-over' : ''}`}>
          {titleCount} / {POST_TITLE_MAX}
        </span>
      </div>

      <div className="profile-post-form-row">
        <label className="profile-post-form-label" htmlFor="post-body">
          Body <span className="profile-post-form-hint">markdown — **bold**, *italic*, [links](url)</span>
        </label>
        <textarea
          id="post-body"
          className={`profile-post-form-textarea ${bodyOver ? 'is-over' : ''}`}
          value={body}
          onChange={(e) => setBody(e.target.value)}
          disabled={submitting}
          rows={8}
          placeholder="Hey Showrunners!&#10;&#10;Tell the story…"
        />
        <span className={`profile-post-form-counter ${bodyOver ? 'is-over' : ''}`}>
          {bodyCount} / {POST_BODY_MAX}
        </span>
      </div>

      <div className="profile-post-form-row">
        <label className="profile-post-form-label" htmlFor="post-image">
          Image <span className="profile-post-form-hint">optional — JPG / PNG / WebP, ≤ 20 MB, auto-compressed to WebP</span>
        </label>
        <input
          ref={fileInputRef}
          id="post-image"
          type="file"
          accept="image/jpeg,image/png,image/webp"
          className="profile-post-form-file"
          onChange={handleFileChange}
          disabled={submitting}
        />
      </div>

      {imagePreview && (
        <div className="profile-post-form-preview">
          <img src={imagePreview} alt="Preview" />
          <div className="profile-post-form-preview-meta">
            {imageMeta && imageMeta.originalSize && (
              <span>
                {(imageMeta.originalSize / 1024).toFixed(0)} KB original
                {imageMeta.compressedSize && (
                  <> → {(imageMeta.compressedSize / 1024).toFixed(0)} KB WebP @ {imageMeta.width}×{imageMeta.height}</>
                )}
              </span>
            )}
            <button
              type="button"
              className="profile-post-form-preview-remove"
              onClick={clearImage}
              disabled={submitting}
            >
              Remove
            </button>
          </div>
        </div>
      )}

      {error && (
        <div className="profile-post-form-error" role="alert">
          {error}
        </div>
      )}

      <div className="profile-post-form-submit-row">
        {stageLabel && <span className="profile-post-form-stage">{stageLabel}</span>}
        <button
          type="submit"
          className="profile-post-form-submit"
          disabled={!canSubmit}
        >
          {submitting ? t('publishing') : t('publish')}
        </button>
      </div>
    </form>
  );
}

function FeedTab({ isAdmin }) {
  const [posts, setPosts] = useState([]);
  const [page, setPage] = useState(1);
  const [hasMore, setHasMore] = useState(false);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  // Initial fetch
  useEffect(() => {
    let cancelled = false;
    (async () => {
      try {
        setLoading(true);
        setError(null);
        const data = await fetchPosts(SHOWRUNNER_SLUG, 1);
        if (cancelled) return;
        setPosts(data.posts);
        setHasMore(data.hasMore);
        setPage(1);
      } catch (err) {
        if (cancelled) return;
        setError(err.message || 'fetch_failed');
      } finally {
        if (!cancelled) setLoading(false);
      }
    })();
    return () => {
      cancelled = true;
    };
  }, []);

  const loadMore = useCallback(async () => {
    try {
      setLoading(true);
      setError(null);
      const nextPage = page + 1;
      const data = await fetchPosts(SHOWRUNNER_SLUG, nextPage);
      setPosts((prev) => [...prev, ...data.posts]);
      setHasMore(data.hasMore);
      setPage(data.page);
    } catch (err) {
      setError(err.message || 'fetch_failed');
    } finally {
      setLoading(false);
    }
  }, [page]);

  const handlePosted = useCallback((newPost) => {
    setPosts((prev) => [newPost, ...prev]);
    // Wait for React to commit the new <ProfilePost> to the DOM, then
    // scroll the freshly-published post into viewport-center. Double
    // rAF defers past the React render + browser layout pass. Falls
    // back gracefully if the element isn't there yet (no error thrown).
    requestAnimationFrame(() => {
      requestAnimationFrame(() => {
        const el = document.getElementById(`post-${newPost.id}`);
        if (el && typeof el.scrollIntoView === 'function') {
          el.scrollIntoView({ behavior: 'smooth', block: 'center' });
        }
      });
    });
  }, []);

  const handleDeleted = useCallback((deletedId) => {
    setPosts((prev) => prev.filter((p) => p.id !== deletedId));
  }, []);

  const handleUpdated = useCallback((updatedPost) => {
    setPosts((prev) => prev.map((p) => (p.id === updatedPost.id ? updatedPost : p)));
  }, []);

  // Authoring/admin controls only on the admin's OWN profile — Akos viewing
  // another showrunner's page must not see the post-form or edit/delete.
  const showAdmin = isAdmin && IS_OWN_ADMIN_PROFILE;

  return (
    <>
      {showAdmin && <ProfilePostForm onPosted={handlePosted} />}
      <ProfileFeed
        posts={posts}
        loading={loading}
        error={error}
        hasMore={hasMore}
        onLoadMore={loadMore}
        isAdmin={showAdmin}
        onDeleted={handleDeleted}
        onUpdated={handleUpdated}
      />
      {!isAdmin && <JoinCTA />}
    </>
  );
}

function AboutSection() {
  const { t } = useI18n();
  return (
    <section className="profile-about">
      <p className="profile-about-paragraph">{t('about_intro')}</p>
      <p className="profile-about-paragraph">{t('about_showrunner_zero')}</p>
    </section>
  );
}

// Discrete "step inside" prompt rendered at the bottom of a non-admin
// viewer's feed. Mirrors the homepage FRAME 06 dual-CTA pattern
// (Fan → /auth, Founding Showrunner → /founding) so a fan arriving
// from a shared link has a frictionless way to opt in to the platform
// without bouncing back to / to find the signup. Hidden when Akos is
// signed in (he doesn't need to join his own profile).
function JoinCTA() {
  const { t } = useI18n();
  return (
    <aside className="profile-cta" aria-label="Join AI Prismatik">
      <p className="profile-cta-eyebrow">{t('cta_eyebrow')}</p>
      <h3 className="profile-cta-title">{t('cta_title')}</h3>
      <p className="profile-cta-body">{t('cta_body')}</p>
      <div className="profile-cta-actions">
        <a className="profile-cta-action profile-cta-action-primary" href="/auth">
          {t('cta_fan')}
        </a>
        <a className="profile-cta-action profile-cta-action-secondary" href="/founding">
          {t('cta_founding')}
        </a>
      </div>
    </aside>
  );
}

// ════════════════════════════════════════════════════════════════════
// App root
// ════════════════════════════════════════════════════════════════════

function App() {
  const [tab, setTab] = useState(() => parseTabFromPath());
  const [isAdmin, setIsAdmin] = useState(false);

  useEffect(() => {
    const handler = () => setTab(parseTabFromPath());
    window.addEventListener('popstate', handler);
    return () => window.removeEventListener('popstate', handler);
  }, []);

  useEffect(() => {
    let cancelled = false;
    fetchIsAdmin().then((v) => {
      if (!cancelled) setIsAdmin(v);
    });
    return () => {
      cancelled = true;
    };
  }, []);

  const handleTabChange = useCallback((t) => {
    navigateTab(t);
    setTab(t);
  }, []);

  return (
    <div className={`profile-page ${isAdmin ? 'is-admin' : ''}`}>
      <ProfileHeader isAdmin={isAdmin} />
      <ProfileTopNav tab={tab} onChange={handleTabChange} />
      <main className="profile-main">
        {tab === 'feed' && <FeedTab isAdmin={isAdmin} />}
        {tab === 'about' && <AboutSection />}
      </main>
    </div>
  );
}

const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
  <I18nProvider>
    <App />
  </I18nProvider>
);
