/* ClientBase, App data layer, modal, toasts, shared form primitives */

const CB_LS_KEY = "cb_app_data_v3";
const CB_USER_KEY = "cb_user_v1";
const CB_COOKIE_KEY = "cb_cookie_consent_v1";

const uid = () => Math.random().toString(36).slice(2, 10);

const slugify = (str) => (str || "")
  .toLowerCase()
  .normalize("NFD")
  .replace(/[\u0300-\u036f]/g, "")
  .replace(/[^a-z0-9]+/g, "-")
  .replace(/^-+|-+$/g, "")
  .slice(0, 28);

const generateBookingSlug = (businessName) => {
  const base = slugify(businessName) || "pro";
  return `${base}-${Math.random().toString(36).slice(2, 6)}`;
};

/* ===== Auth, Supabase si dispo, sinon fallback localStorage ===== */
const _sha256 = async (str) => {
  const buf = new TextEncoder().encode(str);
  const hash = await crypto.subtle.digest("SHA-256", buf);
  return Array.from(new Uint8Array(hash)).map(b => b.toString(16).padStart(2, "0")).join("");
};

const _authListeners = new Set();
const _notifyAuth = () => _authListeners.forEach(fn => { try { fn(cbAuth.getCurrentUser()); } catch (e) { console.error(e); } });

// Cache du user courant (lu de façon synchrone par l'UI).
// Hydraté depuis localStorage au démarrage, rafraîchi depuis Supabase si connecté.
let _currentUser = (() => {
  try { return JSON.parse(localStorage.getItem(CB_USER_KEY) || "null"); }
  catch { return null; }
})();
const _setCurrentUser = (u) => {
  _currentUser = u;
  if (u) localStorage.setItem(CB_USER_KEY, JSON.stringify(u));
  else   localStorage.removeItem(CB_USER_KEY);
  _notifyAuth();
};

// Lit la ligne `businesses` de l'utilisateur et fusionne les infos
// (businessName, ownerName, bookingSlug) dans le cache. Fait aussi la
// création d'urgence si le trigger a raté.
const _hydrateFromSupabase = async (session) => {
  if (!session || !window.cbSupabase) return null;
  const sb = window.cbSupabase;
  const u  = session.user;

  let { data: biz } = await sb
    .from("businesses")
    .select("name, owner, booking_slug")
    .eq("user_id", u.id)
    .maybeSingle();

  // Si le trigger n'a pas encore provisionné la ligne (signup first-time), on l'attend.
  if (!biz) {
    await new Promise(r => setTimeout(r, 400));
    const retry = await sb.from("businesses").select("name, owner, booking_slug").eq("user_id", u.id).maybeSingle();
    biz = retry.data;
  }

  // GUARD anti-race : entre le début de cette fonction et maintenant, un
  // autre code a peut-être fait signOut (ex: pro login gate qui rejette
  // un compte client → signOut → mais _hydrateFromSupabase déjà en cours
  // depuis l'event SIGNED_IN). Si la session n'est plus active, on
  // n'écrase PAS le state cleared. Sinon, le bouton "Mon espace" du
  // user rejeté apparaîtrait quand même.
  try {
    const { data: nowSession } = await sb.auth.getSession();
    if (!nowSession || !nowSession.session) {
      window.cbDebug && window.cbDebug.log("[_hydrateFromSupabase] session disparue pendant l'hydratation → skip");
      return null;
    }
  } catch {}

  // Idem : si c'est un compte client (pas de businesses row), on ne pose
  // PAS _currentUser pro. Le compte client a son propre flow
  // (EspacePage avec token='me') qui lit la session via getUser.
  if (!biz) {
    window.cbDebug && window.cbDebug.log("[_hydrateFromSupabase] aucune business row → user pas pro, skip _setCurrentUser pro");
    return null;
  }

  const meta = u.user_metadata || {};
  const user = {
    id:              u.id,
    email:           u.email,
    emailVerified:   !!u.email_confirmed_at,
    businessName:    biz.name  || meta.business_name || "Mon activité",
    ownerName:       biz.owner || meta.owner_name    || (u.email || "").split("@")[0],
    bookingSlug:     biz.booking_slug || generateBookingSlug(meta.business_name || "pro"),
    createdAt:       u.created_at ? Date.parse(u.created_at) : Date.now(),
  };
  _setCurrentUser(user);
  return user;
};

// Hydratation initiale + validation session orpheline.
// IMPORTANT : si on a un cache localStorage qui ressemble à un user Supabase
// (id UUID v4) mais que Supabase n'a PAS de session active, c'est une
// session zombie (compte supprimé côté DB, token expiré non-refreshable,
// etc.). On nettoie pour éviter d'afficher "Espace pro" pour un fantôme.
const _looksLikeSupabaseUserId = (id) =>
  typeof id === "string" && /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(id);

// Cleanup tous les caches d'auth locaux (pro + client + last audience).
const _cleanupAllAuthCaches = () => {
  try { localStorage.removeItem("cb_client_v1"); } catch {}
  try { localStorage.removeItem("cb_last_audience"); } catch {}
  _setCurrentUser(null);
};

if (window.cbSupabase) {
  window.cbSupabase.auth.getSession().then(async ({ data }) => {
    if (data.session) {
      // Session active : on valide aussi côté serveur que l'user existe
      // toujours (auth.users). getUser() refait un round-trip qui échoue
      // si le compte a été supprimé en DB pendant que le token était cached.
      try {
        const { data: u, error } = await window.cbSupabase.auth.getUser();
        if (error || !u || !u.user) {
          window.cbDebug && window.cbDebug.warn("[auth] session token valide mais user introuvable côté DB → cleanup");
          try { await window.cbSupabase.auth.signOut({ scope: "local" }); } catch {}
          _cleanupAllAuthCaches();
          return;
        }
      } catch {}
      _hydrateFromSupabase(data.session);
    } else if (_currentUser && _looksLikeSupabaseUserId(_currentUser.id)) {
      // Pas de session active + cache local avec un UUID Supabase = zombie.
      // On nettoie. Pour les users legacy (sans id ou id custom) on laisse
      // tel quel pour ne pas casser la migration bêta v1→v2.
      window.cbDebug && window.cbDebug.warn("[auth] cache local user Supabase sans session active → cleanup");
      try { await window.cbSupabase.auth.signOut({ scope: "local" }); } catch {}
      _cleanupAllAuthCaches();
    }
  });
  window.cbSupabase.auth.onAuthStateChange((event, session) => {
    // SIGNED_OUT explicite → on déconnecte vraiment.
    if (event === "SIGNED_OUT") {
      _setCurrentUser(null);
      return;
    }
    // INITIAL_SESSION sans session = au démarrage il n'y a pas de session Supabase.
    // NE PAS effacer le user local (ex. user démo, ou user en fallback localStorage) :
    // on laisse simplement le cache local en place, tel quel.
    if (!session) return;
    // SIGNED_IN / TOKEN_REFRESHED / USER_UPDATED avec session → vraie connexion Supabase.
    // Nettoie tout flag démo résiduel et hydrate depuis la base.
    try {
      localStorage.removeItem("cb_demo_mode");
      sessionStorage.removeItem("cb_demo_welcome_seen");
      sessionStorage.removeItem("cb_demo_backup");
    } catch {}
    _hydrateFromSupabase(session);
  });
}

/* ---- Validations partagées (email + mot de passe) ---- */
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[a-zA-Z]{2,}$/;
const isValidEmail = (s) => EMAIL_RE.test((s || "").trim());

// Règles renforcées : 10 caractères minimum + majuscule + minuscule
// + chiffre + caractère spécial. Inspiré des standards des outils SaaS
// modernes (Stripe, GitHub, etc.).
const passwordRules = (p) => ({
  len:    !!p && p.length >= 10,
  upper:  !!p && /[A-Z]/.test(p),
  lower:  !!p && /[a-z]/.test(p),
  digit:  !!p && /[0-9]/.test(p),
  symbol: !!p && /[^A-Za-z0-9\s]/.test(p),
});
const validatePassword = (p) => {
  const r = passwordRules(p);
  if (!r.len)    return "Mot de passe trop court (10 caractères minimum).";
  if (!r.upper)  return "Le mot de passe doit contenir au moins une majuscule.";
  if (!r.lower)  return "Le mot de passe doit contenir au moins une minuscule.";
  if (!r.digit)  return "Le mot de passe doit contenir au moins un chiffre.";
  if (!r.symbol) return "Le mot de passe doit contenir au moins un caractère spécial (!, ?, @, …).";
  return null;
};

// Score sur 5 = nombre de critères validés. Utilisé par la jauge visuelle.
const scorePassword = (p) => {
  const r = passwordRules(p);
  return [r.len, r.upper, r.lower, r.digit, r.symbol].filter(Boolean).length;
};

const cbAuth = {
  getCurrentUser: () => _currentUser,
  isValidEmail,
  validatePassword,
  scorePassword,

  signup: async ({ email, password, businessName, ownerName, phone }) => {
    email = (email || "").trim().toLowerCase();
    if (!isValidEmail(email)) return { error: "Email invalide. Exemple : prenom@exemple.fr" };
    const pwErr = validatePassword(password);
    if (pwErr) return { error: pwErr };
    if (!businessName || !businessName.trim()) return { error: "Nom de l'activité requis." };
    if (!ownerName || !ownerName.trim()) return { error: "Votre nom est requis." };
    // Téléphone obligatoire côté pro (utilisé pour le support + affichage
    // dans les factures + numéro visible par les clients sur la page de
    // réservation publique). Côté client il reste optionnel (EspaceLogin).
    const phoneTrim = (phone || "").trim();
    if (!phoneTrim) return { error: "Votre téléphone est requis (visible sur vos factures et votre page de RDV)." };
    if (phoneTrim.replace(/\D/g, "").length < 9) return { error: "Téléphone invalide (au moins 9 chiffres)." };

    if (window.cbSupabase) {
      const { data, error } = await window.cbSupabase.auth.signUp({
        email, password,
        options: {
          data: {
            // role = "pro" marque ce compte comme pro côté backend. Sert au
            // gate des deux pages de login (LoginPage refuse les comptes
            // role=client, EspaceLogin refuse les comptes role=pro). Sans
            // cette marque, un compte client pourrait se loguer en pro.
            role: "pro",
            business_name: businessName.trim(),
            owner_name: ownerName.trim(),
            phone: phoneTrim,
          },
        },
      });
      if (error) return { error: _prettifyAuthError(error.message) };
      // Quand "Confirm email" est OFF dans Supabase (recommandé), on a
      // une session immédiate → utilisateur connecté direct.
      if (data.session) {
        const user = await _hydrateFromSupabase(data.session);
        return { ok: true, user };
      }
      // Sinon, ça veut dire que "Confirm email" est ON côté Supabase.
      // On informe sans bloquer.
      return { ok: true, user: null, needsEmailConfirm: true };
    }

    // Fallback localStorage (pas de Supabase)
    const passHash = await _sha256(password);
    const user = {
      email, passHash,
      businessName: businessName.trim(),
      ownerName: ownerName.trim(),
      phone: phoneTrim,
      bookingSlug: generateBookingSlug(businessName),
      createdAt: Date.now(),
    };
    _setCurrentUser(user);
    return { ok: true, user };
  },

  login: async ({ email, password }) => {
    email = (email || "").trim().toLowerCase();
    if (!email || !password) return { error: "Email et mot de passe requis." };
    if (!isValidEmail(email)) return { error: "Email invalide. Exemple : prenom@exemple.fr" };

    if (window.cbSupabase) {
      const { data, error } = await window.cbSupabase.auth.signInWithPassword({ email, password });
      if (error) return { error: _prettifyAuthError(error.message) };
      // Gate symétrique : un compte client ne doit PAS pouvoir se
      // connecter à l'espace pro. On utilise la table `businesses`
      // comme source de vérité (plus fiable que metadata.role qui peut
      // être absent ou mal posé).
      //   businesses count = 0 → client → REJET
      //   businesses count > 0 → pro → autorisé
      // En cas d'échec de la requête (RLS bizarre, réseau), on autorise
      // pour ne pas bloquer un vrai pro sur un transient.
      try {
        const { count } = await window.cbSupabase
          .from("businesses")
          .select("user_id", { count: "exact", head: true })
          .eq("user_id", data.user.id);
        const isPro = (count || 0) > 0;
        window.cbDebug && window.cbDebug.log("[pro login] user.id:", data.user.id, "businesses count:", count, "→ isPro:", isPro);
        if (!isPro) {
          try { await window.cbSupabase.auth.signOut(); } catch {}
          // Cleanup explicite pour bypasser la race avec _hydrateFromSupabase
          // qui pourrait être en cours depuis l'event SIGNED_IN. Sans ça,
          // le user rejeté apparaissait quand même en haut à droite ("Mon
          // espace") car _hydrateFromSupabase finissait après signOut et
          // écrasait le _currentUser=null.
          _cleanupAllAuthCaches();
          // Retour enrichi : permet à l'UI de proposer un CTA "Aller à
          // l'espace client avec mon email pré-rempli" sans repartir de
          // zéro pour le user.
          return {
            error: "Cet email correspond à un compte CLIENT. Utilisez l'espace client.",
            wrongAudience: "client",
            email,
          };
        }
      } catch (e) {
        window.cbDebug && window.cbDebug.warn("[pro login] businesses check failed:", e);
      }
      const user = await _hydrateFromSupabase(data.session);
      return { ok: true, user };
    }

    // Fallback localStorage
    const saved = _currentUser;
    if (!saved) return { error: "Aucun compte trouvé. Créez votre compte d'abord." };
    if (saved.email !== email) return { error: "Email ou mot de passe incorrect." };
    const passHash = await _sha256(password);
    if (saved.passHash !== passHash) return { error: "Email ou mot de passe incorrect." };
    _notifyAuth();
    return { ok: true, user: saved };
  },

  // OAuth Google : 1 clic, l'utilisateur revient connecté.
  // ⚙️ Nécessite Google activé dans Supabase Auth → Providers (Client ID +
  // Secret depuis Google Cloud Console).
  loginWithGoogle: async () => {
    if (!window.cbSupabase) return { error: "Service indisponible." };
    try {
      const { error } = await window.cbSupabase.auth.signInWithOAuth({
        provider: "google",
        options: { redirectTo: window.location.origin + "/v2/app" },
      });
      if (error) return { error: _prettifyAuthError(error.message) };
      return { ok: true }; // redirection en cours
    } catch (e) { return { error: "Connexion Google indisponible." }; }
  },

  // OAuth Apple : idem côté code, mais nécessite un compte Apple Developer
  // ($99/an) + config Sign in with Apple côté Supabase.
  loginWithApple: async () => {
    if (!window.cbSupabase) return { error: "Service indisponible." };
    try {
      const { error } = await window.cbSupabase.auth.signInWithOAuth({
        provider: "apple",
        options: { redirectTo: window.location.origin + "/v2/app" },
      });
      if (error) return { error: _prettifyAuthError(error.message) };
      return { ok: true };
    } catch (e) { return { error: "Connexion Apple indisponible." }; }
  },

  // Lien magique : envoie un email avec un lien à 1 clic. Idéal mobile,
  // pas de mot de passe à retenir. Utilise Supabase signInWithOtp.
  requestMagicLink: async (email) => {
    email = (email || "").trim().toLowerCase();
    if (!isValidEmail(email)) return { error: "Email invalide." };
    if (!window.cbSupabase) return { error: "Service indisponible. Connectez-vous avec votre mot de passe." };
    try {
      const { error } = await window.cbSupabase.auth.signInWithOtp({
        email,
        options: {
          // Le callback arrive sur /v2/?code=… → routé en pro par le détecteur d'audience.
          emailRedirectTo: window.location.origin + "/v2/app",
          shouldCreateUser: false, // on n'autorise QUE la reconnexion (pas de signup furtif)
        },
      });
      if (error) return { error: _prettifyAuthError(error.message) };
      return { ok: true };
    } catch (e) {
      return { error: "Impossible d'envoyer le lien. Réessayez." };
    }
  },

  logout: async () => {
    if (window.cbSupabase) {
      try { await window.cbSupabase.auth.signOut(); } catch (e) { console.error(e); }
    }
    // Cleanup COMPLET : pas seulement le user pro mais aussi les caches
    // d'audience (cb_last_audience) et la session client legacy
    // (cb_client_v1). Sinon après logout, le bouton vitrine continue
    // d'afficher "Espace pro" ou "Espace client" via le fallback
    // lastAudience dans Nav (shared.jsx ~ligne 815).
    _cleanupAllAuthCaches();
  },

  ensureBookingSlug: () => {
    const u = _currentUser;
    if (!u) return null;
    if (u.bookingSlug) return u.bookingSlug;
    u.bookingSlug = generateBookingSlug(u.businessName);
    _setCurrentUser({ ...u });
    return u.bookingSlug;
  },

  updateBookingSlug: (newSlug) => {
    const u = _currentUser;
    if (!u) return null;
    const clean = slugify(newSlug);
    if (!clean) return u.bookingSlug;

    if (window.cbSupabase && u.id) {
      window.cbSupabase
        .from("businesses")
        .update({ booking_slug: clean })
        .eq("user_id", u.id)
        .then(({ error }) => {
          if (error && error.code === "23505") {
            // collision unique, on retombe sur l'ancien slug
            window.cbDebug && window.cbDebug.warn("[cbAuth] slug déjà pris :", clean);
          }
        });
    }
    _setCurrentUser({ ...u, bookingSlug: clean });
    return clean;
  },

  findUserBySlug: (slug) => {
    // Synchrone, ne regarde que le user local. La page publique de réservation
    // appellera la RPC `get_public_booking` à l'étape 5.
    const u = _currentUser;
    if (u && u.bookingSlug === slug) return u;
    return null;
  },

  onChange: (fn) => {
    _authListeners.add(fn);
    return () => _authListeners.delete(fn);
  },

  /** Envoie un email de réinitialisation. Le lien renvoie vers l'app
      avec `?reset=1#access_token=…` → détecté au boot (index.html). */
  resetPassword: async (email) => {
    email = (email || "").trim().toLowerCase();
    if (!isValidEmail(email)) return { error: "Email invalide." };
    if (!window.cbSupabase) return { error: "Fonction indisponible en mode bêta locale." };
    const origin = `${window.location.origin}${window.location.pathname}?reset=1`;
    const { error } = await window.cbSupabase.auth.resetPasswordForEmail(email, { redirectTo: origin });
    if (error) return { error: _prettifyAuthError(error.message) };
    return { ok: true };
  },

  /** Applique un nouveau mot de passe (user doit être authentifié via le lien reçu). */
  updatePassword: async (newPassword) => {
    const pwErr = validatePassword(newPassword);
    if (pwErr) return { error: pwErr };
    if (!window.cbSupabase) return { error: "Fonction indisponible en mode bêta locale." };
    const { error } = await window.cbSupabase.auth.updateUser({ password: newPassword });
    if (error) return { error: _prettifyAuthError(error.message) };
    return { ok: true };
  },

  /** Renvoie un email de confirmation d'inscription à l'utilisateur actuel. */
  resendVerification: async () => {
    if (!window.cbSupabase) return { error: "Fonction indisponible en mode bêta locale." };
    const u = _currentUser;
    if (!u || !u.email) return { error: "Aucun compte connecté." };
    const { error } = await window.cbSupabase.auth.resend({ type: "signup", email: u.email });
    if (error) return { error: _prettifyAuthError(error.message) };
    return { ok: true };
  },

  /** Change l'email du compte. Supabase envoie un email de confirmation au
      nouvel email ; le changement est effectif une fois le lien cliqué. */
  changeEmail: async (newEmail) => {
    newEmail = (newEmail || "").trim().toLowerCase();
    if (!isValidEmail(newEmail)) return { error: "Email invalide." };
    if (!window.cbSupabase) return { error: "Fonction indisponible en mode bêta locale." };
    const u = _currentUser;
    if (u && u.email === newEmail) return { error: "C'est déjà votre email actuel." };
    const { error } = await window.cbSupabase.auth.updateUser({ email: newEmail });
    if (error) return { error: _prettifyAuthError(error.message) };
    return { ok: true };
  },
};

const _prettifyAuthError = (msg) => {
  const m = (msg || "").toLowerCase();
  if (m.includes("already registered") || m.includes("user already"))
    return "Un compte existe déjà avec cet email. Connectez-vous.";
  if (m.includes("invalid login") || m.includes("invalid credentials"))
    return "Email ou mot de passe incorrect.";
  if (m.includes("email not confirmed"))
    return "Email non confirmé. Vérifiez votre boîte de réception.";
  if (m.includes("password should be") || m.includes("weak password"))
    return "Mot de passe trop faible (6 caractères minimum).";
  if (m.includes("rate limit") || m.includes("too many"))
    return "Trop de tentatives. Patientez quelques minutes.";
  if (m.includes("redirect") && m.includes("not allowed"))
    return "URL de redirection non autorisée. Contactez le support.";
  // Erreur 500 / problème SMTP → message clair plutôt que "Internal Server Error"
  if (m.includes("500") || m.includes("internal server") || m.includes("smtp"))
    return "L'envoi d'email a échoué côté serveur. Réessayez dans 2 minutes, si ça persiste, écrivez-nous à clientbase.fr@gmail.com.";
  return msg || "Une erreur est survenue.";
};

const useCurrentUser = () => {
  const [user, setUser] = React.useState(() => cbAuth.getCurrentUser());
  React.useEffect(() => cbAuth.onChange(setUser), []);
  return user;
};

/* ===== Cookie banner ===== */
const useCookieConsent = () => {
  const [consent, setConsent] = React.useState(() => localStorage.getItem(CB_COOKIE_KEY));
  const set = (val) => {
    localStorage.setItem(CB_COOKIE_KEY, val);
    setConsent(val);
  };
  return [consent, set];
};

// Cookie banner avec un peu de personnalité : un cookie 🍪 qui tourne
// à l'apparition + texte décontracté qui ne ressemble pas à de la
// paperasse RGPD. Slide-up depuis le bas, anim douce, audience-color
// pour rester dans la DA du site.
const CookieBanner = ({ onOpenCookies }) => {
  const [consent, setConsent] = useCookieConsent();
  const [mounted, setMounted] = React.useState(false);
  React.useEffect(() => {
    if (consent) return;
    // Petit délai avant d'apparaître pour ne pas dérouter dès l'arrivée
    const t = setTimeout(() => setMounted(true), 800);
    return () => clearTimeout(t);
  }, [consent]);
  if (consent) return null;
  return (
    <div style={{
      position: "fixed", bottom: 16, left: 16, right: 16,
      zIndex: 120, maxWidth: 540, margin: "0 auto",
      background: "var(--surface)",
      border: "1px solid var(--line-strong)",
      borderRadius: 18,
      padding: "18px 20px",
      boxShadow: "0 24px 50px -16px rgba(15,18,30,0.22), 0 8px 16px -6px rgba(15,18,30,0.08), inset 0 1px 0 rgba(255,255,255,0.6)",
      opacity: mounted ? 1 : 0,
      transform: mounted ? "translateY(0) scale(1)" : "translateY(20px) scale(0.98)",
      transition: "opacity .4s cubic-bezier(.22,1,.36,1), transform .45s cubic-bezier(.22,1.4,.36,1)",
    }}>
      <div style={{
        display: "flex", gap: 14, alignItems: "flex-start",
        marginBottom: 14,
      }}>
        {/* 🍪 emoji qui tourne au mount */}
        <div style={{
          width: 44, height: 44, borderRadius: 12, flexShrink: 0,
          background: "linear-gradient(135deg, oklch(85% 0.08 60), oklch(75% 0.12 50))",
          display: "flex", alignItems: "center", justifyContent: "center",
          fontSize: 26, lineHeight: 1,
          boxShadow: "0 4px 12px -4px oklch(75% 0.12 50)",
          animation: mounted ? "cbCookieSpin .8s cubic-bezier(.34, 1.56, .64, 1) both" : "none",
        }}>
          🍪
        </div>
        <div style={{ flex: 1, minWidth: 0 }}>
          <div style={{
            fontFamily: "var(--ff-display)", fontSize: 16, fontWeight: 580,
            color: "var(--ink)", letterSpacing: "-0.015em",
            marginBottom: 4,
          }}>
            Un petit cookie&nbsp;?
          </div>
          <div style={{ fontSize: 13, color: "var(--ink-2)", lineHeight: 1.5 }}>
            Seulement techniques (pas de tracker, pas de pub).
            On veut juste savoir si le site marche bien chez vous.{" "}
            {onOpenCookies && (
              <a href="#" onClick={(e) => { e.preventDefault(); onOpenCookies(); }}
                style={{ color: "var(--accent-ink)", fontWeight: 540, whiteSpace: "nowrap" }}>
                en savoir plus →
              </a>
            )}
          </div>
        </div>
      </div>
      <div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
        <button onClick={() => setConsent("essential")}
          style={{
            flex: 1, minWidth: 0,
            padding: "10px 14px",
            background: "transparent",
            border: "1px solid var(--line-strong)",
            borderRadius: 10,
            fontSize: 13, fontWeight: 540,
            color: "var(--ink-2)", cursor: "pointer",
            fontFamily: "inherit",
            transition: "background .15s ease, border-color .15s ease",
          }}
          onMouseEnter={e => { e.currentTarget.style.background = "var(--bg-alt)"; }}
          onMouseLeave={e => { e.currentTarget.style.background = "transparent"; }}>
          Essentiels uniquement
        </button>
        <button onClick={() => setConsent("all")}
          style={{
            flex: 1, minWidth: 0,
            padding: "10px 14px",
            background: "var(--accent)",
            border: "none",
            borderRadius: 10,
            fontSize: 13, fontWeight: 600,
            color: "#fff", cursor: "pointer",
            fontFamily: "inherit",
            boxShadow: "0 4px 12px -4px var(--accent)",
            transition: "filter .15s ease, transform .12s ease",
          }}
          onMouseEnter={e => { e.currentTarget.style.filter = "brightness(1.08)"; }}
          onMouseLeave={e => { e.currentTarget.style.filter = "brightness(1)"; }}
          onMouseDown={e => { e.currentTarget.style.transform = "scale(0.97)"; }}
          onMouseUp={e => { e.currentTarget.style.transform = "scale(1)"; }}>
          Miam, j'accepte 🤝
        </button>
      </div>
      <style>{`
        @keyframes cbCookieSpin {
          0%   { transform: rotate(-180deg) scale(0); opacity: 0; }
          60%  { transform: rotate(20deg) scale(1.15); opacity: 1; }
          100% { transform: rotate(0deg) scale(1); opacity: 1; }
        }
      `}</style>
    </div>
  );
};

const SEED_DATA = {
  business: {
    name: "Ongles by Léa", owner: "Léa Bernard", initials: "LB", hue: 30,
    // Contact
    phone: "",
    contactEmail: "",
    // Legal info (needed on invoices, French norms)
    address: "",
    postalCode: "",
    city: "",
    siret: "",
    legalForm: "Auto-entrepreneur",
    vatStatus: "franchise",        // "franchise" = exempt (art. 293B), "applicable" = VAT charged
    vatNumber: "",
    vatRate: 20,                    // applied only if vatStatus === "applicable"
    iban: "",
    paymentTermsDays: 0,            // 0 = à réception
    paymentMethods: "Virement, espèces, carte",
    rcsRm: "",                      // Immatriculation RCS (commerçant) ou RM (artisan)
    mediatorName: "",               // Médiateur de la consommation (art. L612-1)
    mediatorUrl: "",
  },
  services: [
    { id: "s1", name: "Gel semi-permanent", price: 45, duration: 1,    categoryId: "cat_p", order: 1 },
    { id: "s2", name: "Pose complète",      price: 60, duration: 1.5,  categoryId: "cat_p", order: 2 },
    { id: "s3", name: "Remplissage",        price: 38, duration: 1.25, categoryId: "cat_p", order: 3 },
    { id: "s4", name: "Nail-art",           price: 25, duration: 0.5,  categoryId: "cat_d", order: 1 },
    { id: "s5", name: "Dépose",             price: 20, duration: 0.5,  categoryId: "cat_d", order: 2 },
    { id: "s6", name: "Dépose + pose",      price: 72, duration: 1.25, categoryId: "cat_p", order: 4 },
  ],
  serviceCategories: [
    { id: "cat_p", name: "Pose & entretien", order: 1 },
    { id: "cat_d", name: "Déco & dépose",     order: 2 },
  ],
  clients: [
    { id: "c1", name: "Léa Morel", email: "lea.m@gmail.com", phone: "06 12 34 56 78", visits: 14, spent: 582, fid: 8, last: "il y a 3 sem.", tags: ["VIP", "Fidèle"], hue: 30, notes: "Préfère les couleurs nude. Allergie à la base Gel X." },
    { id: "c2", name: "Camille Petit", email: "camille.p@free.fr", phone: "06 98 76 54 32", visits: 8, spent: 340, fid: 6, last: "il y a 2 sem.", tags: ["Fidèle"], hue: 330, notes: "" },
    { id: "c3", name: "Inès Dubois", email: "ines.dubois@outlook.fr", phone: "06 11 22 33 44", visits: 22, spent: 892, fid: 10, last: "hier", tags: ["VIP"], hue: 220, notes: "" },
    { id: "c4", name: "Sarah Ben Ali", email: "s.benali@gmail.com", phone: "", visits: 5, spent: 215, fid: 4, last: "il y a 1 sem.", tags: ["Nouvelle"], hue: 180, notes: "" },
    { id: "c5", name: "Emma Vidal", email: "emma.v@gmail.com", phone: "", visits: 3, spent: 128, fid: 2, last: "il y a 2 mois", tags: ["Inactive"], hue: 280, notes: "" },
    { id: "c6", name: "Julie Thomas", email: "julie.t@hotmail.fr", phone: "", visits: 11, spent: 456, fid: 7, last: "il y a 4 jours", tags: ["Fidèle"], hue: 60, notes: "" },
    { id: "c7", name: "Marine Leroy", email: "marine.l@gmail.com", phone: "", visits: 6, spent: 258, fid: 5, last: "il y a 1 sem.", tags: [], hue: 140, notes: "" },
  ],
  // Données démo mappées sur la semaine en cours (lundi = jour 0 relatif).
  // Les vrais offsets sont calculés au boot (window.cbMondayOffset peut être
  // absent si shared.jsx n'a pas encore chargé, on retombe sur 0 par défaut).
  // On génère aussi ~180 jours de RDV passés tous marqués done=true pour
  // que les statistiques (CA mensuel, top prestations, no-show) soient
  // immédiatement parlantes en démo.
  appointments: (() => {
    const mon = (typeof window !== "undefined" && window.cbMondayOffset) ? window.cbMondayOffset() : 0;
    const D = (i) => mon + i;
    const upcoming = [
      { id: "a1",  day: D(0), h: 10,   d: 1,    clientId: "c5", serviceId: "s4", color: "accent" },
      { id: "a2",  day: D(0), h: 14,   d: 2,    clientId: "c6", serviceId: "s2", color: "sage" },
      { id: "a3",  day: D(1), h: 9,    d: 1,    clientId: "c1", serviceId: "s1", color: "accent" },
      { id: "a4",  day: D(1), h: 11,   d: 1.5,  clientId: "c2", serviceId: "s2", color: "sage" },
      { id: "a5",  day: D(1), h: 14,   d: 1.25, clientId: "c3", serviceId: "s3", color: "warn" },
      { id: "a6",  day: D(1), h: 15.75,d: 1.25, clientId: "c4", serviceId: "s6", color: "accent" },
      { id: "a7",  day: D(2), h: 9.5,  d: 1.5,  clientId: "c7", serviceId: "s1", color: "accent" },
      { id: "a8",  day: D(2), h: 13,   d: 1,    clientId: "c6", serviceId: "s4", color: "warn" },
      { id: "a9",  day: D(3), h: 10,   d: 1.5,  clientId: "c3", serviceId: "s3", color: "accent" },
      { id: "a10", day: D(4), h: 11,   d: 2,    clientId: "c5", serviceId: "s4", color: "sage" },
      { id: "a11", day: D(4), h: 14.5, d: 1.25, clientId: "c7", serviceId: "s5", color: "warn" },
      { id: "a12", day: D(5), h: 10,   d: 1.5,  clientId: "c2", serviceId: "s2", color: "accent" },
      { id: "a13", day: D(5), h: 13,   d: 1,    clientId: "c4", serviceId: "s1", color: "sage" },
    ];

    // Historique : 180 derniers jours, 2-4 RDV par jour ouvrable, tous done.
    // PRNG seedé pour que les démos soient déterministes (même contenu à
    // chaque rendu, sinon les stats sautent à chaque re-render).
    let seed = 42;
    const rand = () => { seed = (seed * 9301 + 49297) % 233280; return seed / 233280; };
    const pick = (arr) => arr[Math.floor(rand() * arr.length)];
    const clientIds = ["c1","c2","c3","c4","c5","c6","c7"];
    const serviceIds = ["s1","s2","s3","s4","s5","s6"];
    const colors = ["accent","sage","warn"];
    const todayDate = (typeof window !== "undefined") ? new Date() : null;
    if (todayDate) todayDate.setHours(0,0,0,0);
    const past = [];
    let idCounter = 14;
    for (let offset = -180; offset < 0; offset++) {
      // Skip dimanche : agenda fermé en démo
      if (todayDate) {
        const d = new Date(todayDate); d.setDate(todayDate.getDate() + offset);
        if (d.getDay() === 0) continue;
      }
      const count = 2 + Math.floor(rand() * 3);
      const hours = [9, 10, 11, 13, 14, 15, 16, 17];
      const usedHours = new Set();
      for (let k = 0; k < count; k++) {
        let h;
        do { h = pick(hours); } while (usedHours.has(h));
        usedHours.add(h);
        // 5 % de no-show pour avoir un taux non nul dans les stats
        const isNoShow = rand() < 0.05;
        past.push({
          id: "a" + idCounter++,
          day: offset, h, d: 1 + Math.floor(rand() * 2) * 0.25,
          clientId: pick(clientIds),
          serviceId: pick(serviceIds),
          color: pick(colors),
          done: !isNoShow,
        });
      }
    }
    return [...past, ...upcoming];
  })(),
  messages: {
    "c1": [
      { id: "m1",  from: "them", text: "Bonjour Léa ! Je voulais confirmer pour demain 9h, c'est bien ça ?", time: "10:38" },
      { id: "m2",  from: "me",   text: "Bonjour Léa, oui tout est bien bloqué. Pose gel semi-permanent comme la dernière fois ?", time: "10:40" },
      { id: "m3",  from: "them", text: "Oui, et cette fois je tente un nude rosé si vous avez 🤭", time: "10:41" },
      { id: "m4",  from: "me",   text: "J'ai plusieurs teintes, on choisira ensemble. Hâte de vous voir !", time: "10:42" },
      { id: "m5",  from: "them", text: "Parfait, à demain 9h !", time: "10:42" },
    ],
    "c3": [
      { id: "m6",  from: "them", text: "Bonjour, possible de décaler mon RDV de vendredi à samedi ?", time: "09:14" },
      { id: "m7",  from: "them", text: "Merci d'avance !", time: "09:14" },
    ],
    "c2": [
      { id: "m8",  from: "them", text: "Merci beaucoup 🙏", time: "hier" },
    ],
    "c4": [
      { id: "m9",  from: "them", text: "Vous faites les ongles en acrygel ?", time: "hier" },
    ],
    "c5": [
      { id: "m10", from: "them", text: "Super, je prends !", time: "lun." },
    ],
  },
  conversations: [
    { clientId: "c1", last: "Parfait, à demain 9h !",          time: "10:42", unread: 0 },
    { clientId: "c3", last: "Bonjour, possible de décaler…",   time: "09:14", unread: 2 },
    { clientId: "c2", last: "Merci beaucoup 🙏",                time: "hier",  unread: 0 },
    { clientId: "c4", last: "Vous faites les ongles en…",      time: "hier",  unread: 1 },
    { clientId: "c5", last: "Super, je prends !",              time: "lun.",  unread: 0 },
  ],
  activeConv: "c1",
  invoices: [
    { id: "F-2026-0142", clientId: "c1", date: "21 avril 2026", amount: 45, paid: true },
    { id: "F-2026-0141", clientId: "c2", date: "20 avril 2026", amount: 60, paid: true },
    { id: "F-2026-0140", clientId: "c3", date: "18 avril 2026", amount: 38, paid: false },
    { id: "F-2026-0139", clientId: "c4", date: "17 avril 2026", amount: 72, paid: true },
    { id: "F-2026-0138", clientId: "c6", date: "15 avril 2026", amount: 45, paid: true },
  ],
  invoiceCounter: 143,
  stock: [
    { id: "st1", name: "Base coat OPI",          qty: 12, min: 5,  price: 12.90 },
    { id: "st2", name: "Top coat Gelish",        qty: 3,  min: 5,  price: 18.50 },
    { id: "st3", name: "Gel constructeur nude",  qty: 8,  min: 3,  price: 22.00 },
    { id: "st4", name: "Lingettes cleaner",      qty: 1,  min: 10, price: 4.50  },
    { id: "st5", name: "Vernis semi nude 42",    qty: 6,  min: 3,  price: 14.00 },
    { id: "st6", name: "Lime 180 grit (x10)",    qty: 24, min: 15, price: 8.00  },
  ],
  promos: [
    { id: "pr1", title: "Relance clients inactives", segment: "23 clients · sans RDV depuis 90j", status: "Programmé", detail: "Envoi samedi 10h" },
    { id: "pr2", title: "Anniversaires avril",        segment: "8 clients",                         status: "Actif",     detail: "-15% automatique" },
    { id: "pr3", title: "Créneau libre jeudi",        segment: "Clientes régulières",                status: "Brouillon", detail: "À configurer" },
  ],
  fideliteRules: { visits: 10, rewardLabel: "1 prestation offerte (jusqu'à 45 €)" },
  giftCards: [],
  reviews: [],
  notifications: [
    { id: "n_seed1", type: "booking", title: "Nouvelle réservation", message: "Inès Dubois a réservé un remplissage jeudi 10h.", createdAt: Date.now() - 1000 * 60 * 7,  read: false, clientId: "c3", appointmentId: "a9" },
    { id: "n_seed2", type: "payment", title: "Encaissement de 45 €", message: "Camille P. · Gel semi-permanent",                  createdAt: Date.now() - 1000 * 60 * 47, read: false, clientId: "c2" },
    { id: "n_seed3", type: "review",  title: "Nouvel avis · ★★★★★", message: "« Travail soigné, je recommande ! »",                createdAt: Date.now() - 1000 * 60 * 60 * 2, read: false, clientId: "c1" },
    { id: "n_seed4", type: "no_show", title: "Cliente absente",        message: "Camille L. ne s'est pas présentée à son RDV de 10h.", createdAt: Date.now() - 1000 * 60 * 60 * 6, read: false, clientId: "c2" },
    { id: "n_seed5", type: "stock",   title: "Stock bas",               message: "Top coat Gelish : il ne reste que 3 unités.",      createdAt: Date.now() - 1000 * 60 * 60 * 24, read: true },
  ],
  bookingSettings: {
    enabled: true,
    slotDuration: 30,                     // minutes between slot starts
    leadTimeMinutes: 120,                 // cannot book within X minutes of now
    preferredSocial: "instagram",         // Instagram | Facebook | TikTok | Snapchat | WhatsApp | none
    // Per-day schedule (ISO day: 1=Mon … 7=Sun), each day optionally has a lunch break
    schedule: {
      1: { open: true,  start: 9, end: 19, break: true,  breakStart: 12.5, breakEnd: 13.5 },
      2: { open: true,  start: 9, end: 19, break: true,  breakStart: 12.5, breakEnd: 13.5 },
      3: { open: true,  start: 9, end: 19, break: true,  breakStart: 12.5, breakEnd: 13.5 },
      4: { open: true,  start: 9, end: 19, break: true,  breakStart: 12.5, breakEnd: 13.5 },
      5: { open: true,  start: 9, end: 19, break: true,  breakStart: 12.5, breakEnd: 13.5 },
      6: { open: true,  start: 9, end: 16, break: false, breakStart: 12.5, breakEnd: 13.5 },
      7: { open: false, start: 9, end: 18, break: false, breakStart: 12.5, breakEnd: 13.5 },
    },
    // Vacation / closure periods
    vacations: [],                        // [{ id, start: "2026-05-01", end: "2026-05-10", reason: "Vacances" }]
    // Legacy fields (kept for backward compat)
    hours: { start: 9, end: 18 },
    daysOpen: [1, 2, 3, 4, 5, 6],
    welcomeMessage: "",
    activeServiceIds: ["s1", "s2", "s3", "s4", "s5", "s6"],
    autoConfirm: true,
    pageCustom: {
      theme: "rose",
      font: "elegant",
      bio: "Prothésiste ongulaire à Lyon 6e. Soins minutieux, ambiance cosy. Plus de 200 clientes fidèles depuis 4 ans.",
      banner: "",
      gallery: [],
      socialInstagram: "lea_nails_lyon",
      socialWhatsapp: "",
      showReviews: false,
      showWhatsapp: false,
      showMap: false,
      showSocials: true,
    },
  },
};

/* ===== Public booking helper, used by the public BookingPage =====
   Pure function that returns the next data state + created entity ids.
   The caller writes it back to localStorage and re-renders. */
const commitBooking = (data, input) => {
  const { serviceId, day, h, firstName, lastName, email, phone, notes } = input;
  const fullName = `${firstName.trim()} ${lastName.trim()}`.trim();
  const service = data.services.find(s => s.id === serviceId);
  const duration = service ? service.duration : 1;

  // Match existing client by email (case-insensitive)
  const emailLower = (email || "").toLowerCase().trim();
  const existing = emailLower
    ? data.clients.find(c => (c.email || "").toLowerCase() === emailLower)
    : null;

  let clients = data.clients;
  let clientId;
  let clientWasNew = false;

  if (existing) {
    clientId = existing.id;
    clients = data.clients.map(c =>
      c.id === clientId
        ? {
            ...c,
            name: fullName || c.name,
            phone: (phone || "").trim() || c.phone,
            notes: notes && notes.trim() ? `${c.notes ? c.notes + "\n\n" : ""}${notes.trim()}` : c.notes,
          }
        : c
    );
  } else {
    clientId = "c_" + uid();
    clientWasNew = true;
    clients = [
      {
        id: clientId,
        name: fullName || "Cliente",
        email: email.trim(),
        phone: (phone || "").trim(),
        notes: (notes || "").trim(),
        visits: 0,
        spent: 0,
        fid: 0,
        last: "—",
        tags: ["Nouvelle"],
        hue: (Math.random() * 360) | 0,
      },
      ...data.clients,
    ];
  }

  const appointmentId = "a_" + uid();
  const appointment = {
    id: appointmentId,
    day, h, d: duration,
    clientId, serviceId,
    color: "accent",
    publicBooking: true,
    createdAt: Date.now(),
  };

  const notification = {
    id: "n_" + uid(),
    type: "booking",
    title: "Nouvelle réservation",
    message: `${fullName || "Un client"} a réservé ${service ? service.name : "une prestation"}.`,
    createdAt: Date.now(),
    read: false,
    clientId, appointmentId,
  };

  const nextData = {
    ...data,
    clients,
    appointments: [...data.appointments, appointment],
    notifications: [notification, ...(data.notifications || [])],
  };

  return { nextData, clientId, appointmentId, clientWasNew };
};

// Gabarit d'un "compte cloud vide", pour qu'un nouveau compte démarre
// vraiment à zéro (aucune donnée démo : ni clients, ni RDV, ni services).
const EMPTY_CLOUD_DATA = {
  ...SEED_DATA,
  clients: [], appointments: [], invoices: [], stock: [], services: [],
  serviceCategories: [], giftCards: [], reviews: [],
  notifications: [], invoiceCounter: 1,
  messages: {}, conversations: [], activeConv: null, promos: [],
  business: { ...SEED_DATA.business, name: "", owner: "", initials: "" },
  bookingSettings: { ...SEED_DATA.bookingSettings, vacations: [], pageCustom: null },
};

// Migrations one-shot sur les données persistées en localStorage.
// Chaque migration tourne une seule fois (flag dédié).
const cbMigrateData = (d) => {
  let out = d;
  try {
    // v1, purge des anciennes cartes cadeaux de démo persistées :
    // l'onglet doit démarrer vide pour les comptes en bêta.
    if (localStorage.getItem("cb_migrate_giftcards_v1") !== "1") {
      out = { ...out, giftCards: [] };
      localStorage.setItem("cb_migrate_giftcards_v1", "1");
    }
  } catch (e) { /* ignore */ }
  return out;
};

const useAppData = () => {
  const isCloud = !!(window.cbCloud && window.cbCloud.isActive());

  const [data, setData] = React.useState(() => {
    // Mode cloud : on démarre vide et on hydrate en useEffect (asynchrone)
    if (isCloud) {
      try {
        const saved = localStorage.getItem(CB_LS_KEY + "_cloud");
        if (saved) return cbMigrateData({ ...EMPTY_CLOUD_DATA, ...JSON.parse(saved) });
      } catch (e) { /* ignore */ }
      return EMPTY_CLOUD_DATA;
    }
    // Mode local (pas de session Supabase), seed de démo comme avant
    try {
      const saved = localStorage.getItem(CB_LS_KEY);
      if (saved) return cbMigrateData({ ...SEED_DATA, ...JSON.parse(saved) });
    } catch (e) { /* ignore */ }
    return SEED_DATA;
  });

  // Charge depuis Supabase au mount + à chaque changement de session
  React.useEffect(() => {
    if (!isCloud) return;
    let cancelled = false;
    (async () => {
      try {
        const cloud = await window.cbCloud.loadAll();
        if (cancelled || !cloud) return;
        setData(prev => ({ ...prev, ...cloud }));
      } catch (e) {
        console.error("[cbCloud] loadAll failed", e);
        showToast("Impossible de charger les données (hors ligne ?)", "warn");
      }
    })();
    // Rechargement si la session change (login/logout)
    const off = window.cbSync && window.cbSync.onChange(async (online) => {
      if (!online) return;
      try {
        const cloud = await window.cbCloud.loadAll();
        if (!cancelled && cloud) setData(prev => ({ ...prev, ...cloud }));
      } catch (e) { /* ignore */ }
    });
    return () => { cancelled = true; if (off) off(); };
    // eslint-disable-next-line
  }, [isCloud]);

  // Cache localStorage par mode (cloud / local), sert de fallback offline
  React.useEffect(() => {
    try {
      localStorage.setItem(isCloud ? CB_LS_KEY + "_cloud" : CB_LS_KEY, JSON.stringify(data));
    } catch (e) { /* quota */ }
  }, [data, isCloud]);

  // Realtime : pousse instantanément les changements DB → UI sans refresh.
  // Sert surtout aux nouvelles réservations prises depuis le lien public.
  React.useEffect(() => {
    if (!isCloud || !window.cbCloud || !window.cbCloud.subscribe) return;

    // Helper d'upsert par id immutable (insert si absent, replace si présent)
    const upsert = (arr, row) => {
      const idx = arr.findIndex(x => x.id === row.id);
      if (idx === -1) return [row, ...arr];
      const next = arr.slice();
      next[idx] = { ...next[idx], ...row };
      return next;
    };
    const dropById = (arr, id) => arr.filter(x => x.id !== id);

    const off = window.cbCloud.subscribe({
      onAppointment: ({ row, deleteId }) => setData(d => ({
        ...d,
        appointments: deleteId ? dropById(d.appointments, deleteId) : upsert(d.appointments, row),
      })),
      onClient: ({ row, deleteId }) => setData(d => ({
        ...d,
        clients: deleteId ? dropById(d.clients, deleteId) : upsert(d.clients, row),
      })),
      onInvoice: ({ row, deleteId }) => setData(d => ({
        ...d,
        invoices: deleteId ? dropById(d.invoices, deleteId) : upsert(d.invoices, row),
      })),
      onService: ({ row, deleteId }) => setData(d => ({
        ...d,
        services: deleteId ? dropById(d.services || [], deleteId) : upsert(d.services || [], row),
      })),
      onStock: ({ row, deleteId }) => setData(d => ({
        ...d,
        stock: deleteId ? dropById(d.stock, deleteId) : upsert(d.stock, row),
      })),
      onNotification: ({ row, deleteId }) => setData(d => ({
        ...d,
        notifications: deleteId
          ? dropById(d.notifications || [], deleteId)
          : upsert(d.notifications || [], row),
      })),
      onVacation: ({ row, deleteId }) => setData(d => ({
        ...d,
        bookingSettings: {
          ...(d.bookingSettings || {}),
          vacations: deleteId
            ? dropById((d.bookingSettings && d.bookingSettings.vacations) || [], deleteId)
            : upsert((d.bookingSettings && d.bookingSettings.vacations) || [], row),
        },
      })),
      // booking_settings : reçoit la row entière (mappée camelCase). On
      // merge dans le state local pour que la preview Ma page reflète
      // instantanément ce qui est en DB (y compris pageCustom écrit depuis
      // un autre onglet/device).
      onBookingSettings: ({ row }) => {
        if (!row) return;
        setData(d => ({
          ...d,
          bookingSettings: {
            ...(d.bookingSettings || {}),
            ...row,  // enabled, slotDuration, leadTimeMinutes, schedule, preferredSocial, pageCustom
            // Garde vacations locales (chargées séparément)
            vacations: (d.bookingSettings && d.bookingSettings.vacations) || [],
          },
        }));
      },
      // businesses : nom, slug, avatar, etc., utilisés par la preview Ma page
      onBusiness: ({ row }) => {
        if (!row) return;
        setData(d => ({
          ...d,
          business: {
            ...(d.business || {}),
            ...row,
            // Compat : Ma page lit data.business.businessName, alias sur name
            businessName: row.name || (d.business && d.business.businessName),
          },
        }));
      },
    });
    return off;
  }, [isCloud]);

  // Sync business info depuis le user connecté (mode local uniquement, en
  // cloud, business est chargé depuis la DB).
  React.useEffect(() => {
    if (isCloud) return;
    const user = cbAuth.getCurrentUser();
    if (!user) return;
    if (data.business && data.business.syncedFromUser === user.email) return;
    const initials = (user.ownerName || "").split(" ").map(x => x[0]).slice(0, 2).join("").toUpperCase() || "CB";
    setData(d => ({
      ...d,
      business: {
        ...d.business,
        name: user.businessName,
        owner: user.ownerName,
        initials,
        syncedFromUser: user.email,
      },
    }));
    // eslint-disable-next-line
  }, [isCloud]);

  // resetData wipes everything in the DB (cloud) ou en local. Async :
  // renvoie une promise qui résout quand c'est fait, throw en cas d'erreur
  //, pour que l'UI puisse afficher un toast d'échec.
  const resetData = async () => {
    if (isCloud) {
      await window.cbCloud.resetMyData();
      localStorage.removeItem(CB_LS_KEY + "_cloud");
      // Re-hydrate depuis le cloud (devrait être tout vide)
      const cloud = await window.cbCloud.loadAll();
      setData({ ...EMPTY_CLOUD_DATA, ...(cloud || {}) });
      return;
    }
    localStorage.removeItem(CB_LS_KEY);
    setData(EMPTY_CLOUD_DATA); // Vraiment vide, plus de SEED_DATA
  };

  return [data, setData, resetData];
};

const findClient = (data, id) => data.clients.find(c => c.id === id);
const findService = (data, id) => data.services.find(s => s.id === id);

/* ------------------- Toast system ------------------- */
let _toastListeners = new Set();
let _toastQueue = [];
const _dismissToast = (id) => {
  _toastQueue = _toastQueue.filter(x => x.id !== id);
  _toastListeners.forEach(fn => fn(_toastQueue));
};
/* showToast(msg, kind, opts?)
   opts.action = { label, onClick } → toast becomes 5s with an Undo button */
const showToast = (msg, kind = "success", opts = {}) => {
  const action = opts.action || null;
  const duration = action ? 5200 : 2800;
  const t = { id: Date.now() + Math.random(), msg, kind, action, duration };
  _toastQueue = [..._toastQueue, t];
  _toastListeners.forEach(fn => fn(_toastQueue));
  setTimeout(() => _dismissToast(t.id), duration);
};

// Toast spécialisé pour les notifications in-app : icône typée + titre/message
const showNotifToast = (notif) => {
  if (!notif) return;
  const t = {
    id: Date.now() + Math.random(),
    isNotif: true,
    notif,
    duration: 4800,
  };
  _toastQueue = [..._toastQueue, t];
  _toastListeners.forEach(fn => fn(_toastQueue));
  setTimeout(() => _dismissToast(t.id), t.duration);
};
window.showNotifToast = showNotifToast;

const Toasts = () => {
  const [list, setList] = React.useState(_toastQueue);
  React.useEffect(() => {
    _toastListeners.add(setList);
    return () => { _toastListeners.delete(setList); };
  }, []);
  return (
    <div style={{
      position: "fixed", bottom: 24, right: 24, zIndex: 300,
      display: "flex", flexDirection: "column", gap: 8, pointerEvents: "none",
    }}>
      {list.map(t => t.isNotif ? (
        <NotifToastCard key={t.id} notif={t.notif} duration={t.duration} onDismiss={() => _dismissToast(t.id)}/>
      ) : (
        <div key={t.id} style={{
          padding: "12px 14px 12px 16px",
          background: "var(--surface)",
          border: "1px solid var(--line-strong)",
          borderRadius: 10,
          boxShadow: "var(--sh-2)",
          fontSize: 13.5, color: "var(--ink)",
          display: "flex", gap: 12, alignItems: "center",
          minWidth: 280, pointerEvents: "auto",
          animation: "slideIn 0.2s ease-out",
          overflow: "hidden", position: "relative",
        }}>
          <span style={{
            width: 22, height: 22, borderRadius: 50,
            background: t.kind === "success" ? "var(--sage-soft)" : t.kind === "warn" ? "oklch(96% 0.04 30)" : "var(--accent-soft)",
            color: t.kind === "success" ? "var(--sage)" : t.kind === "warn" ? "oklch(50% 0.18 30)" : "var(--accent-ink)",
            display: "inline-flex", alignItems: "center", justifyContent: "center",
            flexShrink: 0,
          }}>
            <Icon name={t.kind === "success" ? "check" : "bell"} size={13} stroke={2.6}/>
          </span>
          <span style={{ flex: 1, minWidth: 0 }}>{t.msg}</span>
          {t.action && (
            <button
              onClick={() => { try { t.action.onClick(); } catch {} _dismissToast(t.id); }}
              style={{
                fontFamily: "inherit", fontSize: 12.5, fontWeight: 580,
                color: "var(--accent-ink)", background: "transparent",
                border: "none", padding: "4px 8px", borderRadius: 6,
                cursor: "pointer", flexShrink: 0,
              }}
              onMouseEnter={e => e.currentTarget.style.background = "var(--accent-soft)"}
              onMouseLeave={e => e.currentTarget.style.background = "transparent"}>
              {t.action.label}
            </button>
          )}
          {t.action && (
            <div style={{
              position: "absolute", left: 0, bottom: 0, height: 2,
              background: "var(--accent)", opacity: 0.4,
              animation: `cbToastBar ${t.duration}ms linear forwards`,
            }}/>
          )}
        </div>
      ))}
      <style>{`@keyframes cbToastBar { from { width: 100%; } to { width: 0%; } }`}</style>
    </div>
  );
};

/* === Toast spécial Notification, design plus riche, icône typée === */
const NOTIF_TOAST_STYLE = {
  booking: { icon: "✓", bg: "oklch(94% 0.10 145)", color: "oklch(40% 0.16 145)", border: "oklch(85% 0.10 145)" },
  payment: { icon: "€", bg: "oklch(95% 0.07 60)",  color: "oklch(45% 0.14 60)",  border: "oklch(88% 0.10 60)"  },
  review:  { icon: "★", bg: "oklch(95% 0.07 85)",  color: "oklch(45% 0.16 80)",  border: "oklch(88% 0.10 85)"  },
  no_show: { icon: "!", bg: "oklch(95% 0.05 25)",  color: "oklch(48% 0.16 25)",  border: "oklch(88% 0.08 25)"  },
  stock:   { icon: "↓", bg: "oklch(97% 0.04 30)",  color: "oklch(45% 0.14 30)",  border: "oklch(90% 0.06 30)"  },
  default: { icon: "•", bg: "var(--bg-alt)",      color: "var(--ink-2)",        border: "var(--line)"         },
};

const NotifToastCard = ({ notif, duration, onDismiss }) => {
  const style = NOTIF_TOAST_STYLE[notif.type] || NOTIF_TOAST_STYLE.default;
  return (
    <div style={{
      padding: "12px 14px",
      background: "var(--surface)",
      border: `1px solid ${style.border}`,
      borderRadius: 12,
      boxShadow: "0 16px 36px -10px rgba(15,18,30,0.18), 0 4px 10px -4px rgba(15,18,30,0.08)",
      pointerEvents: "auto",
      animation: "slideIn 0.28s cubic-bezier(.22,1,.36,1)",
      minWidth: 320, maxWidth: 380,
      overflow: "hidden", position: "relative",
      cursor: "pointer",
    }}
      onClick={onDismiss}>
      <div style={{ display: "flex", gap: 11, alignItems: "flex-start" }}>
        <span style={{
          width: 32, height: 32, borderRadius: 9,
          background: style.bg, color: style.color,
          display: "inline-flex", alignItems: "center", justifyContent: "center",
          fontSize: 16, fontWeight: 600,
          flexShrink: 0,
        }}>
          {style.icon}
        </span>
        <div style={{ flex: 1, minWidth: 0 }}>
          <div style={{
            fontSize: 13, fontWeight: 580, color: "var(--ink)",
            letterSpacing: "-0.008em", marginBottom: 1,
          }}>{notif.title}</div>
          <div style={{
            fontSize: 12, color: "var(--ink-3)", lineHeight: 1.45,
            overflow: "hidden", textOverflow: "ellipsis",
            display: "-webkit-box", WebkitLineClamp: 2, WebkitBoxOrient: "vertical",
          }}>{notif.message}</div>
        </div>
        <span style={{
          fontSize: 10, fontFamily: "var(--ff-text)",
          color: "var(--ink-4)", textTransform: "uppercase", letterSpacing: "0.05em",
          flexShrink: 0,
        }}>nouveau</span>
      </div>
      <div style={{
        position: "absolute", left: 0, bottom: 0, height: 2,
        background: style.color, opacity: 0.5,
        animation: `cbToastBar ${duration}ms linear forwards`,
      }}/>
    </div>
  );
};

/* ------------------- Pulse system (discrete celebrate) ------------------- */
let _pulseListeners = new Set();
const cbPulse = (kind = "check") => {
  const p = { id: Date.now() + Math.random(), kind };
  _pulseListeners.forEach(fn => fn(p));
};
if (typeof window !== "undefined") window.cbPulse = cbPulse;

const Pulses = () => {
  const [list, setList] = React.useState([]);
  React.useEffect(() => {
    const onPulse = (p) => {
      setList(l => [...l, p]);
      setTimeout(() => setList(l => l.filter(x => x.id !== p.id)), 1100);
    };
    _pulseListeners.add(onPulse);
    return () => { _pulseListeners.delete(onPulse); };
  }, []);
  return (
    <>
      <style>{`
        @keyframes cbPulseHalo { 0% { transform: scale(.6); opacity: 0; } 35% { opacity: .55; } 100% { transform: scale(2.2); opacity: 0; } }
        @keyframes cbPulseCore { 0% { transform: scale(.4); opacity: 0; } 30% { transform: scale(1.05); opacity: 1; } 75% { transform: scale(1); opacity: 1; } 100% { transform: scale(1); opacity: 0; } }
      `}</style>
      <div style={{
        position: "fixed", inset: 0, pointerEvents: "none", zIndex: 290,
        display: "flex", alignItems: "center", justifyContent: "center",
      }}>
        {list.map(p => {
          const isEuro = p.kind === "euro";
          const ring = isEuro ? "var(--sage)" : "var(--accent)";
          const bg   = isEuro ? "var(--sage-soft)" : "var(--accent-soft)";
          const ink  = isEuro ? "oklch(38% 0.10 160)" : "var(--accent-ink)";
          return (
            <div key={p.id} style={{ position: "absolute", width: 56, height: 56 }}>
              <div style={{
                position: "absolute", inset: 0, borderRadius: 50,
                border: `1.5px solid ${ring}`,
                animation: "cbPulseHalo 1000ms cubic-bezier(.22,.61,.36,1) forwards",
              }}/>
              <div style={{
                position: "absolute", inset: 0, borderRadius: 50,
                background: bg, color: ink,
                display: "flex", alignItems: "center", justifyContent: "center",
                animation: "cbPulseCore 950ms cubic-bezier(.22,.61,.36,1) forwards",
                boxShadow: "0 4px 14px -4px rgba(0,0,0,.12)",
              }}>
                <Icon name={isEuro ? "euro" : "check"} size={24} stroke={2.6}/>
              </div>
            </div>
          );
        })}
      </div>
    </>
  );
};

/* ------------------- Modal ------------------- */
const Modal = ({ open, onClose, title, children, wide, footer }) => {
  React.useEffect(() => {
    if (!open) return;
    const onKey = (e) => { if (e.key === "Escape") onClose(); };
    document.addEventListener("keydown", onKey);
    const prev = document.body.style.overflow;
    document.body.style.overflow = "hidden";
    return () => {
      document.removeEventListener("keydown", onKey);
      document.body.style.overflow = prev;
    };
  }, [open, onClose]);

  if (!open) return null;
  return (
    <div onClick={onClose} style={{
      position: "fixed", inset: 0, zIndex: 200,
      background: "rgba(15, 18, 30, 0.45)",
      backdropFilter: "blur(4px)", WebkitBackdropFilter: "blur(4px)",
      display: "flex", alignItems: "center", justifyContent: "center", padding: 24,
      animation: "cbModalFadeIn .2s ease-out both",
    }}>
      <style>{`
        @keyframes cbModalFadeIn { from { opacity: 0; } to { opacity: 1; } }
        @keyframes cbModalIn { from { opacity: 0; transform: translateY(12px) scale(0.97); } to { opacity: 1; transform: translateY(0) scale(1); } }
      `}</style>
      <div onClick={e => e.stopPropagation()} style={{
        background: "var(--surface)",
        border: "1px solid var(--line)",
        borderRadius: 16, boxShadow: "var(--sh-3)",
        width: "100%", maxWidth: wide ? 640 : 440,
        maxHeight: "calc(100vh - 48px)",
        display: "flex", flexDirection: "column",
        overflow: "hidden",
        animation: "cbModalIn .28s cubic-bezier(.22, 1, .36, 1) both",
      }}>
        <div style={{
          padding: "18px 22px",
          borderBottom: "1px solid var(--line)",
          display: "flex", justifyContent: "space-between", alignItems: "center",
          flexShrink: 0,
        }}>
          <div style={{
            fontFamily: "var(--ff-display)", fontSize: 17,
            fontWeight: 580, letterSpacing: "-0.02em",
          }}>{title}</div>
          <button onClick={onClose} aria-label="Fermer" style={{
            background: "transparent", border: "none", cursor: "pointer",
            color: "var(--ink-4)", padding: 4, display: "flex",
          }}>
            <Icon name="close" size={18}/>
          </button>
        </div>
        <div style={{ padding: 22, overflow: "auto", flex: 1 }}>{children}</div>
        {footer && (
          <div style={{
            padding: "16px 22px 18px", borderTop: "1px solid var(--line)",
            background: "var(--surface)",
            display: "flex", justifyContent: "flex-end", alignItems: "center", gap: 10,
            flexShrink: 0,
          }}>{footer}</div>
        )}
      </div>
    </div>
  );
};

/* ------------------- Form primitives ------------------- */
const fieldLabelStyle = {
  display: "block", fontSize: 12.5, fontWeight: 500,
  color: "var(--ink-2)", marginBottom: 6,
};
const fieldInputStyle = {
  width: "100%", maxWidth: "100%", minWidth: 0,
  display: "block",
  padding: "10px 12px",
  fontSize: 16, fontFamily: "inherit",
  background: "var(--bg)",
  border: "1px solid var(--line-strong)",
  borderRadius: 9, color: "var(--ink)", outline: "none",
  boxSizing: "border-box",
  // iOS Safari : neutralise la largeur intrinsèque des input date/time
  WebkitAppearance: "none",
  appearance: "none",
};

const FormField = ({ label, type = "text", value, onChange, placeholder, required, autoFocus, disabled }) => {
  // Pour les champs password : on permet à l'utilisateur de basculer entre
  // "caché" et "visible" via un petit bouton œil à droite de l'input.
  // Utile sur mobile où la saisie est plus error-prone.
  const isPwd = type === "password";
  const [shown, setShown] = React.useState(false);
  const effectiveType = isPwd && shown ? "text" : type;
  return (
    <div>
      <label style={fieldLabelStyle}>{label}{required && <span style={{ color: "var(--accent)" }}> *</span>}</label>
      <div style={{ position: "relative" }}>
        <input type={effectiveType} value={value} onChange={e => onChange(e.target.value)}
          placeholder={placeholder} required={required} autoFocus={autoFocus} disabled={disabled}
          style={{
            ...fieldInputStyle,
            opacity: disabled ? 0.55 : 1,
            paddingRight: isPwd ? 44 : (fieldInputStyle.paddingRight || undefined),
          }}/>
        {isPwd && (
          <button type="button"
            onClick={() => setShown(s => !s)}
            tabIndex={-1}
            aria-label={shown ? "Cacher le mot de passe" : "Afficher le mot de passe"}
            title={shown ? "Cacher le mot de passe" : "Afficher le mot de passe"}
            style={{
              position: "absolute", right: 6, top: "50%", transform: "translateY(-50%)",
              width: 32, height: 32, borderRadius: 8,
              background: "transparent", border: "none", cursor: "pointer",
              display: "flex", alignItems: "center", justifyContent: "center",
              color: "var(--ink-3)", padding: 0,
            }}>
            {shown ? (
              // Icône œil barré (caché)
              <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor"
                strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
                <path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24"/>
                <line x1="1" y1="1" x2="23" y2="23"/>
              </svg>
            ) : (
              // Icône œil ouvert (visible)
              <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor"
                strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
                <path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/>
                <circle cx="12" cy="12" r="3"/>
              </svg>
            )}
          </button>
        )}
      </div>
    </div>
  );
};

const FormSelect = ({ label, value, onChange, options, required }) => (
  <div>
    <label style={fieldLabelStyle}>{label}{required && <span style={{ color: "var(--accent)" }}> *</span>}</label>
    <select value={value} onChange={e => onChange(e.target.value)} required={required}
      style={{ ...fieldInputStyle, appearance: "none", backgroundImage: "url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%238889a0' stroke-width='2'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E\")", backgroundRepeat: "no-repeat", backgroundPosition: "right 10px center", backgroundSize: 16, paddingRight: 34 }}>
      {options.map(o => {
        const [val, lbl] = Array.isArray(o) ? o : [o, o];
        return <option key={val} value={val}>{lbl}</option>;
      })}
    </select>
  </div>
);

const FormTextarea = ({ label, value, onChange, placeholder, rows = 3 }) => (
  <div>
    <label style={fieldLabelStyle}>{label}</label>
    <textarea value={value} onChange={e => onChange(e.target.value)}
      placeholder={placeholder} rows={rows}
      style={{ ...fieldInputStyle, resize: "vertical", minHeight: rows * 24, fontFamily: "inherit" }}/>
  </div>
);

/* Global style for toast animation */
(function injectCbKeyframes() {
  if (document.getElementById("cb-keyframes")) return;
  const style = document.createElement("style");
  style.id = "cb-keyframes";
  style.textContent = `
    @keyframes slideIn { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: translateY(0); } }
    .cb-row-hover:hover { background: var(--bg-alt) !important; cursor: pointer; }
    .cb-hide-scrollbar { scrollbar-width: none; -ms-overflow-style: none; }
    .cb-hide-scrollbar::-webkit-scrollbar { display: none; width: 0; height: 0; }
  `;
  document.head.appendChild(style);
})();

Object.assign(window, {
  useAppData, findClient, findService, uid, slugify,
  Modal, Toasts, showToast,
  FormField, FormSelect, FormTextarea,
  fieldLabelStyle, fieldInputStyle,
  cbAuth, useCurrentUser, CookieBanner,
  commitBooking, CB_LS_KEY, SEED_DATA,
  // Expose les helpers password pour que la checklist visuelle (PasswordField
  // dans pages-b.jsx) puisse vérifier chaque critère individuellement.
  passwordRules, scorePassword,
});
