// MA-Trip — global store + Thailand 2027 trip data.
// Exposes window.Store, window.useStore, window.A, helpers.

const STORAGE_KEY = "ma-trip-store-v4";

// ---- Empty defaults — user fills in everything manually ---------------------

const TRIP = {
  name: "",
  startDate: "",
  endDate: "",
  days: 0,
  destinations: [],
  travelers: [],
  totalBudget: 0,
  currency: "₪",
  localCurrency: null,
};

function travelerNames() {
  const t = (Store.state && Store.state.trip && Store.state.trip.travelers) || [];
  return t.map(p => p.name).filter(Boolean);
}
function travelerPayerOptions() {
  const names = travelerNames();
  if (!names.length) return ["אני"];
  return [...names, "משותף"];
}
window.travelerNames = travelerNames;
window.travelerPayerOptions = travelerPayerOptions;

const DEFAULT_RATES = { ILS: 1, USD: 0.27, THB: 9.5, EUR: 0.25, GBP: 0.22, JPY: 40, AUD: 0.42, CAD: 0.36, CHF: 0.24 };
const CURRENCY_SYMBOL = { ILS: "₪", USD: "$", THB: "฿", EUR: "€", GBP: "£", JPY: "¥", AUD: "A$", CAD: "C$", CHF: "Fr" };
const CURRENCY_LABEL  = { ILS: "שקל", USD: "דולר", THB: "באט", EUR: "אירו", GBP: "פאונד", JPY: "ין", AUD: "דולר אוסטרלי", CAD: "דולר קנדי", CHF: "פרנק" };

const DEST_TO_CCY = {
  "תאילנד": "THB", "thailand": "THB", "בנגקוק": "THB", "bangkok": "THB", "פוקט": "THB", "phuket": "THB", "צ'יאנג מאי": "THB", "chiang mai": "THB", "krabi": "THB", "קראבי": "THB", "ko samui": "THB", "phi phi": "THB",
  "יפן": "JPY", "japan": "JPY", "tokyo": "JPY", "טוקיו": "JPY", "kyoto": "JPY", "אוסקה": "JPY", "osaka": "JPY",
  "ארה\"ב": "USD", "usa": "USD", "united states": "USD", "ניו יורק": "USD", "new york": "USD", "לוס אנג'לס": "USD", "los angeles": "USD",
  "אנגליה": "GBP", "uk": "GBP", "london": "GBP", "לונדון": "GBP", "england": "GBP",
  "צרפת": "EUR", "france": "EUR", "פריז": "EUR", "paris": "EUR", "ספרד": "EUR", "spain": "EUR", "מדריד": "EUR", "ברצלונה": "EUR", "איטליה": "EUR", "italy": "EUR", "רומא": "EUR", "אירופה": "EUR", "europe": "EUR", "גרמניה": "EUR", "germany": "EUR", "ברלין": "EUR", "berlin": "EUR", "הולנד": "EUR", "אמסטרדם": "EUR",
  "אוסטרליה": "AUD", "australia": "AUD", "sydney": "AUD",
  "קנדה": "CAD", "canada": "CAD", "toronto": "CAD",
  "שוויץ": "CHF", "switzerland": "CHF", "zurich": "CHF",
};

function autoDetectCurrency(destinations) {
  for (const d of (destinations || [])) {
    const name = (typeof d === "string" ? d : (d && d.name) || "").toLowerCase().trim();
    for (const k in DEST_TO_CCY) {
      if (name.includes(k.toLowerCase())) return DEST_TO_CCY[k];
    }
  }
  return null;
}

function convertAmount(amount, fromCur, toCur, rates) {
  const r = rates || DEFAULT_RATES;
  const a = Number(amount) || 0;
  const ils = a / (r[fromCur] || 1);
  return ils * (r[toCur] || 1);
}

Object.assign(window, { CURRENCY_SYMBOL, CURRENCY_LABEL, autoDetectCurrency, convertAmount });

function _haptic(kind) {
  if (!navigator.vibrate) return;
  const patterns = { tap: 8, success: [12, 30, 12], warn: [20, 40, 20], heavy: 25 };
  try { navigator.vibrate(patterns[kind || "tap"] || 8); } catch (e) {}
}
window._haptic = _haptic;

const CATEGORY_KEYWORDS = {
  "אוכל": ["אוכל","פיצה","המבורגר","סטייק","סושי","פסטה","קפה","cafe","coffee","food","pizza","burger","sushi","lunch","dinner","breakfast","מסעדה","ארוחה","פלאפל","שווארמה","חומוס","אוכל רחוב","באר","bar","pub","פאב"],
  "תחבורה": ["תחבורה","מונית","taxi","uber","grab","bolt","אוטובוס","bus","רכבת","train","metro","subway","סקוטר","scooter","טוקטוק","tuktuk","ferry","מעבורת","שכירות רכב","rental","דלק","gas","fuel"],
  "לינה": ["לינה","מלון","hotel","הוסטל","hostel","airbnb","guesthouse","resort","אכסניה"],
  "טיסות": ["טיסה","flight","airline","אל על","easyjet","ryanair","שדה תעופה","airport","luggage","tax"],
  "אטרקציות": ["אטרקציה","טיול","tour","סיור","museum","מוזיאון","park","פארק","beach","חוף","ticket","כרטיס","entrance","כניסה","spa","ספא","massage","עיסוי","diving","צלילה","snorkel"],
  "קניות": ["קניות","shopping","קנייה","store","חנות","mall","קניון","souvenir","מזכרת","market","שוק"],
  "מתנות": ["מתנה","gift","present","מזכרת","souvenir"],
  "ביטוח": ["ביטוח","insurance","פוליסה","policy"],
};
function guessExpenseCategory(title) {
  if (!title) return null;
  const t = title.toLowerCase();
  for (const [cat, words] of Object.entries(CATEGORY_KEYWORDS)) {
    for (const w of words) {
      if (t.includes(w.toLowerCase())) return cat;
    }
  }
  return null;
}
window.guessExpenseCategory = guessExpenseCategory;

function currentUser() {
  const s = Store.state;
  const id = s.currentUserId;
  if (!id) return null;
  return (s.accounts || []).find(u => u.id === id) || null;
}
function userName(id) {
  if (!id) return "—";
  const s = Store.state;
  const u = (s.accounts || []).find(x => x.id === id);
  return u ? u.name : id;
}
function stamp(item, isEdit) {
  const u = currentUser();
  const now = Date.now();
  const uid = u ? u.id : null;
  if (isEdit) {
    return { ...item, updatedBy: uid, updatedAt: now };
  }
  return { ...item, createdBy: uid, createdAt: now, updatedBy: uid, updatedAt: now };
}
window.currentUser = currentUser;
window.userName = userName;
window.stamp = stamp;

const INITIAL = {
  trip: TRIP,
  stays: [],
  places: [],

  // UI state
  itineraryDay: 0,
  memoryFilter: "הכל",
  mapFilter: "הכל",
  docTab: "הכל",
  planningDest: TRIP.destinations[0] || "",

  // Drafts + collections
  draftMemory: { title: "", journal: "", date: "", time: "", location: "", locationCoords: null, photos: [], voiceNote: null, bgMusic: null },
  currentMemoryId: null,
  documents: [],
  memories: [],
  reminders: [],
  packing: [],
  packingCategories: [],
  packingTotals: {},
  expenses: [],
  activities: [],
  savedPlaceIds: [],
  spots: [],
  lists: [],
  currentSpotsList: null,
  currentSpotsListId: null,
  rates: DEFAULT_RATES,
  ratesUpdated: 0,
  accounts: [],
  currentUserId: null,
  seenOnboarding: false,
  spotMapPicker: null,
  actionSheet: null,
  editSheet: null,
  confirmSheet: null,
  toast: null,
  // ---- sync zero-values (must exist for loadState fallthrough guards) ----
  serverTripId: null,
  serverVersion: 0,
  syncEnabled: false,
  r2Refs: {},
  lastSyncAt: 0,
};

// Errors caught here can't toast yet (A isn't built). Stash on window so App can surface on mount.
function loadState() {
  try {
    const raw = localStorage.getItem(STORAGE_KEY);
    if (raw) {
      const parsed = JSON.parse(raw);
      // Deep-merge known nested shapes so new fields in INITIAL.trip / INITIAL.draftMemory
      // survive a schema bump for users with existing saved state.
      return {
        ...INITIAL,
        ...parsed,
        trip: { ...INITIAL.trip, ...(parsed.trip || {}) },
        draftMemory: { ...INITIAL.draftMemory, ...(parsed.draftMemory || {}) },
      };
    }
  } catch (e) {
    console.error("[Store] loadState failed:", e);
    window.__maTripLoadError = e;
  }
  return INITIAL;
}

let _state = loadState();
const _listeners = new Set();
let _lastQuotaToastAt = 0;
let _persistTimer = null;

function _doPersist() {
  _persistTimer = null;
  try {
    const { actionSheet, editSheet, confirmSheet, toast, spotMapPicker, ...rest } = _state;
    // Strip password material from persisted accounts — XSS or a malicious
    // browser extension reading localStorage["ma-trip-store-v4"] would otherwise
    // get every user's PBKDF2 hash + salt for offline cracking.
    if (Array.isArray(rest.accounts)) {
      rest.accounts = rest.accounts.map(u => {
        if (!u) return u;
        const { passwordHash, salt, ...safe } = u;
        return safe;
      });
    }
    localStorage.setItem(STORAGE_KEY, JSON.stringify(rest));
  } catch (e) {
    console.error("[Store] persist failed:", e);
    const isQuota = e && (e.name === "QuotaExceededError" || /quota/i.test(String(e.message)));
    const now = Date.now();
    if (now - _lastQuotaToastAt > 30000) {
      _lastQuotaToastAt = now;
      try {
        if (window.A && window.A.showToast) {
          window.A.showToast(isQuota ? "אחסון מלא — חלק מהשינויים לא נשמרו" : "שגיאה בשמירה", "error");
        }
      } catch (_) {}
    }
  }
}

// Trailing-debounced persist (~300ms). A burst of state changes coalesces into
// one localStorage write. The full-state JSON.stringify of a memory-heavy trip
// can be 8–20ms — was running on every tap/filter/sheet open.
function persist() {
  if (_persistTimer) clearTimeout(_persistTimer);
  _persistTimer = setTimeout(_doPersist, 300);
}

// Synchronously flush any in-flight debounced write. Callers that mutate state
// they CANNOT afford to lose (e.g. completing onboarding right before tab close)
// invoke this immediately after Store.set.
function flushPersist() {
  if (_persistTimer) { clearTimeout(_persistTimer); _doPersist(); }
}

if (typeof window !== "undefined") {
  window.addEventListener("pagehide", flushPersist);
  window.addEventListener("beforeunload", flushPersist);
  window.addEventListener("visibilitychange", () => { if (document.visibilityState === "hidden") flushPersist(); });
}

// iOS-style palette — 12 system colors
const COLORS = [
  { name: "אדום",   hex: "#ff3b30" },
  { name: "כתום",   hex: "#ff9500" },
  { name: "צהוב",   hex: "#ffcc00" },
  { name: "ירוק",   hex: "#34c759" },
  { name: "מנטה",   hex: "#00c7be" },
  { name: "טורקיז", hex: "#30b0c7" },
  { name: "ציאן",   hex: "#32ade6" },
  { name: "כחול",   hex: "#007aff" },
  { name: "סגול",   hex: "#5856d6" },
  { name: "ארגמן",  hex: "#af52de" },
  { name: "ורוד",   hex: "#ff2d55" },
  { name: "אפור",   hex: "#8e8e93" },
];
function notify() { _listeners.forEach(fn => fn()); }

const Store = {
  get state() { return _state; },
  set(updater) {
    _state = typeof updater === "function" ? updater(_state) : { ..._state, ...updater };
    persist();
    notify();
    // Sync to Cloudflare backend (opt-in, debounced). Internal helper —
    // no-op until window.MA_SYNC_ENABLED or state.syncEnabled is true.
    if (typeof scheduleSync === "function") scheduleSync();
  },
  subscribe(fn) { _listeners.add(fn); return () => _listeners.delete(fn); },
  reset() { _state = INITIAL; persist(); notify(); },
};

// Hand-rolled selector hook. Bails out on equal selector output so each
// Store.set only re-renders the components whose slice actually changed.
// Uses shallow-equal for object/array literal returns (common pattern:
// `useStore(s => ({ a: s.a, b: s.b }))`).
function _shallowEq(a, b) {
  if (a === b) return true;
  if (!a || !b || typeof a !== "object" || typeof b !== "object") return false;
  if (Array.isArray(a) !== Array.isArray(b)) return false;
  const ka = Object.keys(a), kb = Object.keys(b);
  if (ka.length !== kb.length) return false;
  for (const k of ka) if (a[k] !== b[k]) return false;
  return true;
}
function useStore(selector) {
  const [, force] = React.useReducer(x => x + 1, 0);
  // Latest selector in a ref so the mounted-once subscriber always calls the
  // CURRENT selector. prevRef is updated ONLY inside the subscriber after a
  // diff — writing it during render was making the comparison compare a value
  // against itself, silently swallowing any Store change between two renders.
  const selRef = React.useRef(selector || ((s) => s));
  selRef.current = selector || ((s) => s);
  const prevRef = React.useRef(undefined);
  if (prevRef.current === undefined) prevRef.current = selRef.current(Store.state);
  React.useEffect(() => {
    // Re-seed on mount in case state changed between render and effect.
    prevRef.current = selRef.current(Store.state);
    return Store.subscribe(() => {
      const next = selRef.current(Store.state);
      if (!_shallowEq(next, prevRef.current)) {
        prevRef.current = next;
        force();
      }
    });
  }, []);
  return selRef.current(Store.state);
}

// ---- Date helpers -----------------------------------------------------------
const HEBREW_WEEKDAYS = ["א׳", "ב׳", "ג׳", "ד׳", "ה׳", "ו׳", "ש׳"];
const HEBREW_MONTHS = ["ינו׳", "פבר׳", "מרץ", "אפר׳", "מאי", "יוני", "יולי", "אוג׳", "ספט׳", "אוק׳", "נוב׳", "דצמ׳"];

function getTripDayInfo(i) {
  const sd = Store.state.trip.startDate;
  if (!sd || !/^\d{4}-\d{2}-\d{2}$/.test(sd)) {
    return { dayOfMonth: "—", weekday: "", month: "", year: "", iso: "" };
  }
  const [y, m, d] = sd.split("-").map(Number);
  const date = new Date(y, m - 1, d);
  date.setDate(date.getDate() + i);
  const yyyy = date.getFullYear();
  const mm = String(date.getMonth() + 1).padStart(2, "0");
  const dd = String(date.getDate()).padStart(2, "0");
  return {
    dayOfMonth: date.getDate(),
    weekday: HEBREW_WEEKDAYS[date.getDay()],
    month: HEBREW_MONTHS[date.getMonth()],
    year: date.getFullYear(),
    iso: `${yyyy}-${mm}-${dd}`,
  };
}

function getCountdown() {
  const sd = Store.state.trip.startDate;
  if (!sd || !/^\d{4}-\d{2}-\d{2}$/.test(sd)) return { days: 0, hours: 0, started: false, unset: true };
  const [y, m, d] = sd.split("-").map(Number);
  const target = new Date(y, m - 1, d).getTime();
  const now = Date.now();
  const diffMs = target - now;
  if (diffMs <= 0) return { days: 0, hours: 0, started: true };
  return { days: Math.floor(diffMs / 86400000), hours: Math.floor((diffMs % 86400000) / 3600000), started: false };
}

function getStayForDay(dayIdx) {
  return Store.state.stays.find(s => dayIdx >= s.startDayIdx && dayIdx < s.endDayIdx) || null;
}

function destName(d) { return typeof d === "string" ? d : (d && d.name) || ""; }
function destCoords(d) { return typeof d === "object" && d && d.lat != null ? { lat: d.lat, lng: d.lng } : null; }
function getDestinations() { return (Store.state.trip.destinations || []).map(d => ({ name: destName(d), coords: destCoords(d) })); }

// ---- Trip stats (read-only aggregation for the recap screen) ---------------
function computeTripStats(state) {
  const s = state || Store.state;
  const trip = s.trip || {};
  const memories = s.memories || [];
  const spots = s.spots || [];
  const expenses = s.expenses || [];
  const activities = s.activities || [];

  const startInfo = getTripDayInfo(0);
  const endInfo = getTripDayInfo(Math.max(0, (trip.days || 1) - 1));
  const dateRange = trip.startDate
    ? `${startInfo.dayOfMonth}.${startInfo.month} — ${endInfo.dayOfMonth}.${endInfo.month}`
    : "";

  let photosCount = 0;
  for (const m of memories) photosCount += (m.photos || []).length;

  const memoriesWithPhotos = memories.filter(m => (m.photos || []).length > 0);
  const recentPhotos = memoriesWithPhotos.slice(0, 4).map(m => m.photos[0]).filter(Boolean);

  // Expense fields per addExpense schema: e.a = amount (string/number), e.cat = category label, e.s = display sub.
  const expAmount = (e) => parseFloat(e.a != null ? e.a : e.amount) || 0;
  const totalSpent = expenses.reduce((sum, e) => sum + expAmount(e), 0);
  const dailyAvg = trip.days ? totalSpent / trip.days : 0;

  const byCat = {};
  for (const e of expenses) {
    const k = e.cat || e.sub || "אחר";
    byCat[k] = (byCat[k] || 0) + expAmount(e);
  }
  let topCat = null, topCatSum = 0;
  for (const k of Object.keys(byCat)) {
    if (byCat[k] > topCatSum) { topCat = k; topCatSum = byCat[k]; }
  }

  // Top spot categories — count by cat
  const spotsByCat = {};
  for (const sp of spots) spotsByCat[sp.cat || "other"] = (spotsByCat[sp.cat || "other"] || 0) + 1;
  const spotCatTop = Object.entries(spotsByCat).sort((a, b) => b[1] - a[1]).slice(0, 3);

  // Most-loved memory (liked → fall back to newest with photos → fall back to first memory)
  const liked = memories.find(m => m.liked && (m.photos || []).length > 0);
  const topMemory = liked || memoriesWithPhotos[0] || memories[0] || null;

  const destNames = (trip.destinations || []).map(d => destName(d)).filter(Boolean);

  return {
    daysCount: trip.days || 0,
    dateRange,
    destNames,
    memoriesCount: memories.length,
    photosCount,
    recentPhotos,
    spotsCount: spots.length,
    spotCatTop,
    activitiesCount: activities.length,
    totalSpent,
    dailyAvg,
    topCat, topCatSum,
    topMemory,
    currency: trip.currency || "₪",
    travelersCount: (trip.travelers || []).length,
  };
}

// ---- Audio helpers --------------------------------------------------------
async function recordAudio(maxSeconds = 60) {
  if (!navigator.mediaDevices || !window.MediaRecorder) {
    throw new Error("המכשיר לא תומך בהקלטה");
  }
  const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
  const mime = MediaRecorder.isTypeSupported("audio/webm;codecs=opus") ? "audio/webm;codecs=opus"
             : MediaRecorder.isTypeSupported("audio/mp4") ? "audio/mp4"
             : "";
  const rec = mime ? new MediaRecorder(stream, { mimeType: mime, audioBitsPerSecond: 64000 })
                   : new MediaRecorder(stream);
  const chunks = [];
  let timer;
  const result = new Promise((resolve, reject) => {
    rec.ondataavailable = e => { if (e.data && e.data.size > 0) chunks.push(e.data); };
    rec.onerror = reject;
    rec.onstop = async () => {
      stream.getTracks().forEach(t => t.stop());
      clearTimeout(timer);
      const blob = new Blob(chunks, { type: rec.mimeType || "audio/webm" });
      const reader = new FileReader();
      reader.onload = () => resolve({ dataUrl: reader.result, mime: blob.type, sizeKb: Math.round(blob.size / 1024) });
      reader.onerror = reject;
      reader.readAsDataURL(blob);
    };
  });
  rec.start();
  timer = setTimeout(() => { if (rec.state === "recording") rec.stop(); }, maxSeconds * 1000);
  return { recorder: rec, result };
}

function readFileAsDataUrl(file) {
  return new Promise((resolve, reject) => {
    const r = new FileReader();
    r.onload = () => resolve({ dataUrl: r.result, mime: file.type, name: file.name, sizeKb: Math.round(file.size / 1024) });
    r.onerror = reject;
    r.readAsDataURL(file);
  });
}

// ---- Photo helpers --------------------------------------------------------
// Returns an "idb://<id>" ref. Original blob (resized) lives in IndexedDB.
function readAndResizePhoto(file, maxSize = 1600, quality = 0.84) {
  return new Promise((resolve, reject) => {
    const reader = new FileReader();
    reader.onload = () => {
      const img = new Image();
      img.onload = () => {
        const canvas = document.createElement("canvas");
        let w = img.width, h = img.height;
        if (w > maxSize || h > maxSize) {
          if (w > h) { h = Math.round(h * (maxSize / w)); w = maxSize; }
          else       { w = Math.round(w * (maxSize / h)); h = maxSize; }
        }
        canvas.width = w; canvas.height = h;
        const ctx = canvas.getContext("2d");
        ctx.drawImage(img, 0, 0, w, h);
        canvas.toBlob(async (blob) => {
          if (!blob) { reject(new Error("toBlob failed")); return; }
          try {
            if (window.MaMedia && window.MaMedia.put) {
              try {
                const ref = await window.MaMedia.put(blob, { mime: "image/jpeg", origName: file.name });
                resolve(ref);
              } catch (e) {
                // Safari standalone caps IDB ~50MB then throws QuotaExceededError.
                // Surface it to the user so they know to free space before the
                // trip data layer goes silently broken.
                if (e && (e.name === "QuotaExceededError" || /quota/i.test(e.message || ""))) {
                  if (A && A.showToast) A.showToast("אחסון מלא — חסר מקום לתמונה", "error");
                }
                reject(e);
              }
            } else {
              // Fallback: dataURL if IDB not available (e.g. Safari private mode)
              const r = new FileReader();
              r.onload = () => resolve(r.result);
              r.onerror = reject;
              r.readAsDataURL(blob);
            }
          } catch (e) { reject(e); }
        }, "image/jpeg", quality);
      };
      img.onerror = reject;
      img.src = reader.result;
    };
    reader.onerror = reject;
    reader.readAsDataURL(file);
  });
}

// ---- Actions ----------------------------------------------------------------
const A = {
  // ---- Generic sheets ------------------------------------------------------
  openEditSheet(config)  { Store.set({ editSheet: config }); },
  closeEditSheet()       { Store.set({ editSheet: null }); },
  openConfirm(config)    { Store.set({ confirmSheet: config }); },
  closeConfirm()         { Store.set({ confirmSheet: null }); },
  showToast(message, kind) {
    // Capture the timestamp BEFORE Store.set — was reading Store.state.toast.t
    // afterward, which leaked to a race when another showToast fired in the
    // same microtask (the dismiss timer kept the second toast forever).
    const t = Date.now();
    Store.set({ toast: { message, kind: kind || "info", t } });
    setTimeout(() => {
      if (Store.state.toast && Store.state.toast.t === t) Store.set({ toast: null });
    }, 2400);
  },
  showUndoToast(message, undoFn, ttlMs, onExpire) {
    const t = Date.now();
    const ttl = ttlMs || 5000;
    let undone = false;
    const wrappedUndo = () => {
      undone = true;
      try { undoFn && undoFn(); }
      catch (e) { console.warn("[showUndoToast.undo]", e); }
    };
    Store.set({ toast: { message, kind: "info", t, action: { label: "בטל", fn: wrappedUndo } } });
    setTimeout(() => {
      if (Store.state.toast && Store.state.toast.t === t) Store.set({ toast: null });
      if (!undone && typeof onExpire === "function") {
        // Support async onExpire — Promise rejection is logged, not swallowed.
        try {
          const r = onExpire();
          if (r && typeof r.catch === "function") r.catch(e => console.warn("[showUndoToast.onExpire async]", e));
        } catch (e) {
          console.warn("[showUndoToast.onExpire]", e);
        }
      }
    }, ttl);
  },
  _confirmDelete(label, deleteFn) {
    A.openConfirm({
      title: "למחוק?",
      body: label,
      confirmLabel: "מחק",
      destructive: true,
      onConfirm: deleteFn,
    });
  },

  // packing
  togglePacking(id) {
    Store.set(s => ({ ...s, packing: s.packing.map(p => p.id === id ? { ...p, done: !p.done } : p) }));
  },
  addPacking(cat) {
    A.openEditSheet({
      title: "פריט חדש ל" + cat,
      submitLabel: "הוסיפי",
      fields: [
        { key: "name", label: "שם הפריט", type: "text", required: true, autoFocus: true },
        { key: "qty",  label: "כמות",      type: "number", value: "1" },
      ],
      onSubmit: (v) => {
        const id = "p" + Date.now();
        Store.set(s => ({ ...s, packing: [...s.packing, stamp({ id, cat, name: v.name.trim(), qty: parseInt(v.qty) || 1, done: false }, false)] }));
      },
    });
  },
  addPackingFAB() {
    const cats = (Store.state.packingCategories && Store.state.packingCategories.length
      ? Store.state.packingCategories
      : ["בגדים", "טיפוח", "חשמל", "תרופות", "מסמכים", "אחר"]);
    A.openEditSheet({
      title: "פריט אריזה חדש",
      submitLabel: "הוסיפי",
      fields: [
        { key: "name", label: "שם הפריט",   type: "text", required: true, autoFocus: true },
        { key: "cat",  label: "קטגוריה",     type: "combo", options: cats, value: cats[0] || "כללי" },
        { key: "qty",  label: "כמות",        type: "number", value: "1" },
      ],
      onSubmit: (v) => {
        const cat = (v.cat || "כללי").trim() || "כללי";
        const id = "p" + Date.now();
        Store.set(s => ({
          ...s,
          packing: [...s.packing, stamp({ id, cat, name: v.name.trim(), qty: parseInt(v.qty) || 1, done: false }, false)],
          packingCategories: s.packingCategories.includes(cat) ? s.packingCategories : [...s.packingCategories, cat],
        }));
      },
    });
  },
  deletePacking(id) {
    const item = Store.state.packing.find(p => p.id === id);
    if (!item) return;
    Store.set(s => ({ ...s, packing: s.packing.filter(p => p.id !== id) }));
    if (window._haptic) window._haptic("warn");
    A.showUndoToast(`נמחק: ${item.name}`, () => {
      Store.set(s => ({ ...s, packing: [...s.packing, item] }));
    });
  },

  // reminders
  toggleReminder(id) {
    Store.set(s => ({ ...s, reminders: s.reminders.map(r => r.id === id ? { ...r, done: !r.done } : r) }));
  },
  addReminder() {
    A.openEditSheet({
      title: "תזכורת חדשה",
      submitLabel: "הוסיפי",
      fields: [
        { key: "title",   label: "כותרת",     type: "text", required: true, autoFocus: true },
        { key: "when",    label: "מתי?",      type: "text", placeholder: "מחר 09:00", value: "השבוע" },
        { key: "section", label: "דחיפות",    type: "select", options: ["דחוף", "השבוע", "החודש"], value: "השבוע" },
      ],
      onSubmit: (v) => {
        const section = ["דחוף", "השבוע", "החודש"].includes(v.section) ? v.section : "השבוע";
        const id = "r" + Date.now();
        Store.set(s => ({
          ...s,
          reminders: [...s.reminders, stamp({ id, section, title: v.title.trim(), sub: "", when: v.when || "השבוע", urgent: section === "דחוף", done: false }, false)],
        }));
      },
    });
  },

  // memories
  likeMemory(id) {
    Store.set(s => ({ ...s, memories: s.memories.map(m => m.id === id ? { ...m, liked: !m.liked } : m) }));
  },
  openMemory(id) {
    Store.set({ currentMemoryId: id });
  },
  deleteMemory(id) {
    const item = Store.state.memories.find(m => m.id === id);
    if (!item) return;
    Store.set(s => ({ ...s, memories: s.memories.filter(m => m.id !== id), currentMemoryId: null }));
    if (window._haptic) window._haptic("warn");
    A.showUndoToast(`נמחק: ${item.title}`, () => {
      Store.set(s => ({ ...s, memories: [item, ...s.memories] }));
    });
  },

  editMemory(id) {
    const item = Store.state.memories.find(m => m.id === id);
    if (!item) return;
    A.openEditSheet({
      title: "עריכת זיכרון",
      submitLabel: "שמרי",
      fields: [
        { key: "title",    label: "כותרת",         type: "text",     value: item.title, required: true, autoFocus: true },
        { key: "location", label: "מיקום",         type: "text",     value: item.location || "" },
        { key: "date",     label: "תאריך",         type: "date",     value: item.date || "" },
        { key: "time",     label: "שעה",           type: "text",     value: item.time || "", placeholder: "20:00" },
        { key: "journal",  label: "יומן / תיאור",  type: "textarea", value: item.journal || "" },
      ],
      onSubmit: (v) => {
        Store.set(s => ({
          ...s,
          memories: s.memories.map(m => m.id === id ? stamp({
            ...m,
            title: v.title.trim(),
            location: (v.location || "").trim(),
            date: (v.date || "").trim(),
            time: (v.time || "").trim(),
            journal: v.journal || "",
            sub: (v.location || "").trim() || (v.date || "").trim() || m.sub,
          }, true) : m),
        }));
      },
    });
  },

  addCommentToMemory(id) {
    A.openEditSheet({
      title: "הוסף תגובה",
      submitLabel: "פרסמי",
      fields: [
        { key: "text", label: "תגובה", type: "textarea", required: true, autoFocus: true, placeholder: "מה את חושבת על הרגע הזה?" },
      ],
      onSubmit: (v) => {
        const text = (v.text || "").trim();
        if (!text) return;
        const c = { id: "c" + Date.now(), text, ts: new Date().toISOString() };
        Store.set(s => ({
          ...s,
          memories: s.memories.map(m => m.id === id ? { ...m, comments: [...(m.comments || []), c] } : m),
        }));
      },
    });
  },

  deleteComment(memId, commentId) {
    Store.set(s => ({
      ...s,
      memories: s.memories.map(m => m.id === memId ? { ...m, comments: (m.comments || []).filter(c => c.id !== commentId) } : m),
    }));
  },

  // expenses
  addExpense() {
    const local = Store.state.trip.localCurrency;
    const curOpts = local && local !== "ILS" ? [local, "ILS", "USD", "EUR"] : ["ILS", "USD", local].filter((x, i, a) => x && a.indexOf(x) === i);
    if (!curOpts.length) curOpts.push("ILS");
    A.openEditSheet({
      title: "הוצאה חדשה",
      submitLabel: "הוסיפי",
      fields: [
        { key: "t",   label: "שם ההוצאה",  type: "text",   required: true, autoFocus: true },
        { key: "a",   label: "סכום",        type: "number", value: "0" },
        { key: "cur", label: "מטבע",        type: "select", options: curOpts, value: curOpts[0] },
        { key: "sub", label: "קטגוריה",     type: "select", options: ["אוכל", "טיסות", "תחבורה", "לינה", "אטרקציות", "קניות", "מתנות", "ביטוח", "אחר"], value: "אחר" },
        { key: "by",  label: "מי שילם?",    type: "select", options: window.travelerPayerOptions(), value: window.travelerPayerOptions()[0] },
      ],
      onSubmit: (v) => {
        // Hebrew users type "12,50" with a comma decimal separator. Stock
        // parseFloat treats "," as a terminator and returns 12 — silently
        // dropped ₪.50 per entry, compounded across a trip.
        const a = parseFloat(String(v.a || "").replace(/[^\d.,\-]/g, "").replace(",", ".")) || 0;
        const id = "e" + Date.now();
        const cur = v.cur || "ILS";
        const payer = v.by || window.travelerPayerOptions()[0];
        // Smart category: if user left "אחר" but title hints another → auto-fix
        let cat = v.sub || "אחר";
        if (cat === "אחר") {
          const guess = window.guessExpenseCategory(v.t || "");
          if (guess) cat = guess;
        }
        const entry = stamp({ id, t: v.t.trim(), s: cat + " · היום", cat, a, cur, by: payer }, false);
        Store.set(s => ({ ...s, expenses: [entry, ...s.expenses] }));
        if (window._haptic) window._haptic("success");
      },
    });
  },

  // stays
  toggleStayPaid(id) {
    Store.set(s => ({ ...s, stays: s.stays.map(st => st.id === id ? { ...st, paid: !st.paid } : st) }));
  },

  // activities
  addActivity(day) {
    A.openEditSheet({
      title: "פעילות חדשה",
      submitLabel: "הוסיפי",
      fields: [
        { key: "title", label: "שם הפעילות",     type: "text", required: true, autoFocus: true },
        { key: "time",  label: "שעה",            type: "text", placeholder: "10:00" },
        { key: "loc",   label: "מיקום",          type: "text" },
        { key: "note",  label: "הערה / טיפ",     type: "textarea" },
      ],
      onSubmit: (v) => {
        const time = v.time || "";
        const id = "a" + Date.now();
        Store.set(s => ({
          ...s,
          activities: [...s.activities, stamp({
            id, day, time, title: v.title.trim(), loc: v.loc || "",
            note: v.note || null, type: "general",
            hours: time ? { state: "flex", value: time } : null,
          }, false)],
        }));
      },
    });
  },
  deleteActivity(id) {
    const item = Store.state.activities.find(a => a.id === id);
    if (!item) return;
    Store.set(s => ({ ...s, activities: s.activities.filter(a => a.id !== id) }));
    if (window._haptic) window._haptic("warn");
    A.showUndoToast(`נמחק: ${item.title}`, () => {
      Store.set(s => ({ ...s, activities: [...s.activities, item] }));
    });
  },

  // filters / pickers
  setMemoryFilter(f) { Store.set({ memoryFilter: f }); },
  setMapFilter(f)    { Store.set({ mapFilter: f }); },
  setDocTab(t)       { Store.set({ docTab: t }); },
  setDay(d)          { Store.set({ itineraryDay: d }); },
  setPlanningDest(d) { Store.set({ planningDest: d }); },

  // places
  toggleSavedPlace(id) {
    Store.set(s => ({
      ...s,
      savedPlaceIds: s.savedPlaceIds.includes(id) ? s.savedPlaceIds.filter(x => x !== id) : [...s.savedPlaceIds, id],
    }));
  },

  // draft / publish
  setDraft(patch) { Store.set(s => ({ ...s, draftMemory: { ...s.draftMemory, ...patch } })); },

  async addDraftPhotos(fileList) {
    const files = Array.from(fileList || []);
    if (!files.length) return;
    // Process all photos in parallel + single Store.set so 5 photos take ~1s
    // instead of ~5s, and we trigger one re-render + one persist() instead of
    // N. Failed photos are skipped, a single toast summarizes failures.
    const results = await Promise.allSettled(files.map(f => readAndResizePhoto(f)));
    const refs = [];
    let failed = 0;
    for (const r of results) {
      if (r.status === "fulfilled" && r.value) refs.push(r.value);
      else { failed++; console.warn("[addDraftPhotos] one failed:", r.reason); }
    }
    if (refs.length) {
      Store.set(s => ({
        ...s,
        draftMemory: { ...s.draftMemory, photos: [...(s.draftMemory.photos || []), ...refs] },
      }));
      if (window._haptic) window._haptic("tap");
    }
    if (failed > 0) {
      A.showToast(failed === files.length ? "טעינת התמונות נכשלה" : `${failed} תמונות לא נטענו`, "error");
    }
  },
  removeDraftPhoto(idx) {
    Store.set(s => ({
      ...s,
      draftMemory: { ...s.draftMemory, photos: (s.draftMemory.photos || []).filter((_, i) => i !== idx) },
    }));
  },
  publishMemory() {
    const d = Store.state.draftMemory;
    if (!d.title || !d.title.trim()) { A.showToast("חסרה כותרת", "error"); return false; }
    const _moodPool = ["ph-lisbon", "ph-azulejo", "ph-cafe", "ph-cliff", "ph-night", "ph-vine", "ph-beach", "ph-pasteis"];
    const newMem = {
      id: "m" + Date.now(),
      cls: _moodPool[Math.floor(Math.random() * _moodPool.length)],
      span: "sm",
      title: d.title.trim(),
      sub: d.location || d.date || "חוויה חדשה",
      journal: d.journal || "",
      date: d.date || "",
      time: d.time || "",
      location: d.location || "",
      locationCoords: d.locationCoords || null,
      liked: false,
      photos: (d.photos || []).slice(),
      voiceNote: d.voiceNote || null,
      bgMusic: d.bgMusic || null,
    };
    const stamped = stamp(newMem, false);
    Store.set(s => ({
      ...s,
      memories: [stamped, ...s.memories],
      draftMemory: { title: "", journal: "", date: "", time: "", location: "", locationCoords: null, photos: [], voiceNote: null, bgMusic: null },
    }));
    // Push any newly-attached blobs to R2 when sync is enabled.
    if (A.scheduleMediaSync) A.scheduleMediaSync();
    return true;
  },

  // ---- Action sheet ---------------------------------------------------------
  openSheet(sheet) { Store.set({ actionSheet: sheet }); },
  closeSheet()     { Store.set({ actionSheet: null }); },
  askEditDelete(label, editFn, deleteFn) {
    A.openSheet({
      title: label,
      options: [
        { label: "ערוך", action: editFn },
        { label: "מחק", destructive: true, action: () => A._confirmDelete(label, deleteFn) },
      ],
    });
  },

  askActions(label, opts) {
    // opts: { collection, itemId, onEdit, onDelete }
    A.openSheet({
      title: label,
      options: [
        { label: "ערוך",  action: opts.onEdit },
        { label: "צבע",   action: () => A.openColorSheet(opts.collection, opts.itemId, label) },
        { label: "מחק",   destructive: true, action: () => A._confirmDelete(label, opts.onDelete) },
      ],
    });
  },

  setItemColor(collection, itemId, hex) {
    Store.set(s => ({
      ...s,
      [collection]: (s[collection] || []).map(it => it.id === itemId ? { ...it, color: hex } : it),
    }));
  },

  openColorSheet(collection, itemId, label) {
    const item = (Store.state[collection] || []).find(x => x.id === itemId);
    Store.set({
      actionSheet: {
        type: "color",
        title: label || "בחר צבע",
        collection,
        itemId,
        currentColor: (item && item.color) || null,
      },
    });
  },

  // ---- Trip / budget editing -----------------------------------------------
  editTotalBudget() {
    const cur = Store.state.trip.totalBudget || 0;
    A.openEditSheet({
      title: "תקציב כולל לטיול",
      submitLabel: "שמרי",
      fields: [{ key: "v", label: "סכום (₪)", type: "number", value: String(cur), required: true, autoFocus: true }],
      onSubmit: (v) => {
        const n = parseFloat(v.v);
        if (isNaN(n) || n < 0) { A.showToast("מספר לא תקין", "error"); return; }
        Store.set(s => ({ ...s, trip: { ...s.trip, totalBudget: n } }));
      },
    });
  },
  editTripName() {
    A.openEditSheet({
      title: "שם הטיול",
      submitLabel: "שמרי",
      fields: [{ key: "v", label: "שם", type: "text", value: Store.state.trip.name, required: true, autoFocus: true }],
      onSubmit: (v) => { Store.set(s => ({ ...s, trip: { ...s.trip, name: v.v.trim() } })); },
    });
  },
  editTripStartDate() {
    A.openEditSheet({
      title: "תאריך התחלה",
      submitLabel: "שמרי",
      fields: [{ key: "v", label: "תאריך", type: "date", value: Store.state.trip.startDate, required: true, autoFocus: true }],
      onSubmit: (v) => {
        if (!/^\d{4}-\d{2}-\d{2}$/.test((v.v || "").trim())) { A.showToast("פורמט: 2027-01-18", "error"); return; }
        Store.set(s => ({ ...s, trip: { ...s.trip, startDate: v.v.trim() } }));
      },
    });
  },
  editTripDays() {
    A.openEditSheet({
      title: "מספר ימים בטיול",
      submitLabel: "שמרי",
      fields: [{ key: "v", label: "ימים", type: "number", value: String(Store.state.trip.days), required: true, autoFocus: true }],
      onSubmit: (v) => {
        const n = parseInt(v.v);
        if (isNaN(n) || n < 1) { A.showToast("ערך לא תקין", "error"); return; }
        Store.set(s => ({ ...s, trip: { ...s.trip, days: n } }));
      },
    });
  },
  tripWizard() {
    const t = Store.state.trip;
    const existingTravelers = (t.travelers || []).map(p => p.name).join(", ");
    const existingDests = (t.destinations || []).map(d => destName(d)).join(", ");
    const curOpts = ["—", "THB", "USD", "EUR", "GBP", "JPY", "AUD", "CAD", "CHF"];
    function daysBetween(a, b) {
      if (!/^\d{4}-\d{2}-\d{2}$/.test(a) || !/^\d{4}-\d{2}-\d{2}$/.test(b)) return 0;
      const da = new Date(a + "T00:00:00").getTime();
      const db = new Date(b + "T00:00:00").getTime();
      return Math.max(1, Math.round((db - da) / 86400000) + 1);
    }
    A.openEditSheet({
      title: "טיול חדש",
      submitLabel: "צרי",
      fields: [
        { key: "name",         label: "שם הטיול",                        type: "text", value: t.name || "", required: true, autoFocus: true, placeholder: "תאילנד 2026" },
        { key: "startDate",    label: "תאריך התחלה",                       type: "date", value: t.startDate || "", required: true },
        { key: "endDate",      label: "תאריך סיום",                        type: "date", value: t.endDate || "" },
        { key: "destinations", label: "יעדים (מופרדים בפסיק)",              type: "text", value: existingDests, placeholder: "בנגקוק, פוקט, צ'יאנג מאי" },
        { key: "travelers",    label: "נוסעים (מופרדים בפסיק)",             type: "text", value: existingTravelers, placeholder: "אני, בן/בת זוג, ילד..." },
        { key: "totalBudget",  label: "תקציב כולל (₪)",                    type: "number", value: String(t.totalBudget || 0) },
        { key: "localCurrency",label: "מטבע מקומי ביעד",                   type: "select", options: curOpts, value: t.localCurrency || "—" },
      ],
      onSubmit: async (v) => {
        if (!/^\d{4}-\d{2}-\d{2}$/.test((v.startDate || "").trim())) { A.showToast("חסר תאריך התחלה", "error"); return; }
        let days = 0;
        if (v.endDate && /^\d{4}-\d{2}-\d{2}$/.test(v.endDate.trim())) {
          days = daysBetween(v.startDate.trim(), v.endDate.trim());
        }
        if (!days) {
          days = t.days || 7;
          // Tell the user we made an assumption — silent 7-day default used to
          // bite users who skipped endDate and never noticed the trip length.
          A.showToast(`הוגדר ל-${days} ימים — ניתן לשנות בעריכת הטיול`, "info");
        }
        const budget = parseFloat(v.totalBudget) || 0;

        const newDestNames = (v.destinations || "").split(/[,\n]/).map(x => x.trim()).filter(Boolean);
        const existingByName = Object.fromEntries((t.destinations || []).map(d => [destName(d), d]));
        const destinations = newDestNames.map(n => existingByName[n] || n);

        const newTravelerNames = (v.travelers || "").split(/[,\n]/).map(x => x.trim()).filter(Boolean);
        const existingTravelersByName = Object.fromEntries((t.travelers || []).map(p => [p.name, p]));
        const travelers = newTravelerNames.map(n => existingTravelersByName[n] || { id: "p" + Date.now() + Math.random().toString(36).slice(2, 5), name: n });

        const localCurrency = v.localCurrency && v.localCurrency !== "—"
          ? v.localCurrency
          : (window.autoDetectCurrency(destinations) || null);

        Store.set(s => ({
          ...s,
          trip: {
            ...s.trip,
            name: v.name.trim(),
            startDate: v.startDate.trim(),
            endDate: v.endDate ? v.endDate.trim() : "",
            days,
            destinations,
            travelers,
            totalBudget: budget,
            localCurrency,
          },
        }));

        // Geocode newly added destinations in background
        for (const d of destinations) {
          if (typeof d === "string") {
            try {
              const r = await window.forwardGeocode(d);
              if (r && r[0]) A.setDestinationCoords(d, { lat: r[0].lat, lng: r[0].lng });
            } catch (e) { console.warn("[tripWizard] geocode failed for", d, e); }
          }
        }
        if (localCurrency) A.fetchRates();
      },
    });
  },

  async fetchRates() {
    // Throws on failure so callers (pull-to-refresh) can show a real error toast
    // instead of the unconditional "עודכן" success.
    const ac = new AbortController();
    const tid = setTimeout(() => ac.abort(), 8000);
    let resp;
    try {
      resp = await fetch("https://open.er-api.com/v6/latest/ILS", { signal: ac.signal });
    } finally {
      clearTimeout(tid);
    }
    if (!resp.ok) throw new Error("rates http " + resp.status);
    const r = await resp.json();
    if (r && r.rates) {
      const next = { ILS: 1 };
      ["USD", "THB", "EUR", "GBP", "JPY", "AUD", "CAD", "CHF"].forEach(k => {
        if (r.rates[k]) next[k] = r.rates[k];
      });
      Store.set({ rates: { ...DEFAULT_RATES, ...next }, ratesUpdated: Date.now() });
    }
  },

  openTripEdit() {
    A.openSheet({
      title: "עריכת פרטי טיול",
      options: [
        { label: "שם הטיול",            action: () => A.editTripName() },
        { label: "תאריך התחלה",          action: () => A.editTripStartDate() },
        { label: "מספר ימים",            action: () => A.editTripDays() },
        { label: "תקציב כולל",           action: () => A.editTotalBudget() },
        { label: "שיתוף טיול (קישור)",    action: () => A.shareTrip() },
      ],
    });
  },

  // ---- Packing edit ---------------------------------------------------------
  editPacking(id) {
    const item = Store.state.packing.find(p => p.id === id);
    if (!item) return;
    const cats = Store.state.packingCategories.length ? Store.state.packingCategories : ["בגדים", "טיפוח", "חשמל", "תרופות", "מסמכים", "אחר"];
    A.openEditSheet({
      title: "עריכת פריט",
      submitLabel: "שמרי",
      fields: [
        { key: "name", label: "שם הפריט", type: "text",   value: item.name, required: true, autoFocus: true },
        { key: "cat",  label: "קטגוריה",   type: "combo",  options: cats, value: item.cat },
        { key: "qty",  label: "כמות",      type: "number", value: String(item.qty) },
      ],
      onSubmit: (v) => {
        const cat = (v.cat || item.cat).trim() || item.cat;
        const qty = parseInt(v.qty) || item.qty;
        Store.set(s => ({
          ...s,
          packing: s.packing.map(p => p.id === id ? stamp({ ...p, name: v.name.trim(), cat, qty }, true) : p),
          packingCategories: s.packingCategories.includes(cat) ? s.packingCategories : [...s.packingCategories, cat],
        }));
      },
    });
  },

  // ---- Reminder edit + delete ----------------------------------------------
  editReminder(id) {
    const item = Store.state.reminders.find(r => r.id === id);
    if (!item) return;
    A.openEditSheet({
      title: "עריכת תזכורת",
      submitLabel: "שמרי",
      fields: [
        { key: "title", label: "כותרת",       type: "text", value: item.title, required: true, autoFocus: true },
        { key: "sub",   label: "תיאור משני",  type: "text", value: item.sub || "" },
        { key: "when",  label: "מתי?",        type: "text", value: item.when || "" },
        { key: "section", label: "דחיפות",     type: "select", options: ["דחוף", "השבוע", "החודש"], value: item.section || "השבוע" },
      ],
      onSubmit: (v) => {
        const section = ["דחוף", "השבוע", "החודש"].includes(v.section) ? v.section : (item.section || "השבוע");
        Store.set(s => ({
          ...s,
          reminders: s.reminders.map(r => r.id === id ? stamp({ ...r, title: v.title.trim(), sub: v.sub || "", when: v.when || item.when, section, urgent: section === "דחוף" }, true) : r),
        }));
      },
    });
  },
  deleteReminder(id) {
    const item = Store.state.reminders.find(r => r.id === id);
    if (!item) return;
    Store.set(s => ({ ...s, reminders: s.reminders.filter(r => r.id !== id) }));
    if (window._haptic) window._haptic("warn");
    A.showUndoToast(`נמחק: ${item.title}`, () => {
      Store.set(s => ({ ...s, reminders: [...s.reminders, item] }));
    });
  },

  // ---- Expense edit + delete ------------------------------------------------
  editExpense(id) {
    const item = Store.state.expenses.find(e => e.id === id);
    if (!item) return;
    const local = Store.state.trip.localCurrency;
    const curOpts = [...new Set([item.cur || "ILS", local, "ILS", "USD", "EUR"].filter(Boolean))];
    const cats = ["אוכל", "טיסות", "תחבורה", "לינה", "אטרקציות", "קניות", "מתנות", "ביטוח", "אחר"];
    const payerOpts = window.travelerPayerOptions();
    const payers = [...new Set([item.by, ...payerOpts].filter(Boolean))];
    A.openEditSheet({
      title: "עריכת הוצאה",
      submitLabel: "שמרי",
      fields: [
        { key: "t",   label: "שם",          type: "text",   value: item.t, required: true, autoFocus: true },
        { key: "a",   label: "סכום",         type: "number", value: String(item.a) },
        { key: "cur", label: "מטבע",         type: "select", options: curOpts, value: item.cur || "ILS" },
        { key: "sub", label: "קטגוריה",      type: "select", options: cats, value: item.cat || cats[cats.length - 1] },
        { key: "by",  label: "מי שילם",      type: "select", options: payers, value: item.by || payers[0] },
      ],
      onSubmit: (v) => {
        // Hebrew comma decimal normalization — see addExpense for context.
        const a = parseFloat(String(v.a || "").replace(/[^\d.,\-]/g, "").replace(",", ".")) || 0;
        Store.set(s => ({
          ...s,
          expenses: s.expenses.map(e => e.id === id ? stamp({
            ...e, t: v.t.trim(), a, cur: v.cur || e.cur || "ILS",
            cat: v.sub || e.cat, s: (v.sub || e.cat || "אחר") + " · היום",
            by: v.by || e.by,
          }, true) : e),
        }));
      },
    });
  },
  deleteExpense(id) {
    const idx = Store.state.expenses.findIndex(e => e.id === id);
    const item = Store.state.expenses[idx];
    if (!item) return;
    Store.set(s => ({ ...s, expenses: s.expenses.filter(e => e.id !== id) }));
    if (window._haptic) window._haptic("warn");
    A.showUndoToast(`נמחק: ${item.t}`, () => {
      Store.set(s => {
        const next = s.expenses.slice();
        next.splice(idx, 0, item);
        return { ...s, expenses: next };
      });
    });
  },

  // ---- Activity edit --------------------------------------------------------
  editActivity(id) {
    const item = Store.state.activities.find(a => a.id === id);
    if (!item) return;
    A.openEditSheet({
      title: "עריכת פעילות",
      submitLabel: "שמרי",
      fields: [
        { key: "title", label: "כותרת",       type: "text",     value: item.title, required: true, autoFocus: true },
        { key: "time",  label: "שעה",         type: "text",     value: item.time || "", placeholder: "10:00" },
        { key: "loc",   label: "מיקום",       type: "text",     value: item.loc || "" },
        { key: "note",  label: "הערה / טיפ",  type: "textarea", value: item.note || "" },
      ],
      onSubmit: (v) => {
        Store.set(s => ({
          ...s,
          activities: s.activities.map(a => a.id === id ? stamp({
            ...a, title: v.title.trim(), time: v.time || "", loc: v.loc || "",
            note: v.note || null,
            hours: v.time ? { state: "flex", value: v.time } : null,
          }, true) : a),
        }));
      },
    });
  },

  // ---- Stay edit + delete ---------------------------------------------------
  editStay(id) {
    const item = Store.state.stays.find(s => s.id === id);
    if (!item) return;
    A.openEditSheet({
      title: "עריכת לינה",
      submitLabel: "שמרי",
      fields: [
        { key: "destination", label: "יעד",            type: "text", value: item.destination, required: true, autoFocus: true },
        { key: "note",        label: "הערה",            type: "text", value: item.note || "", placeholder: "חלק 1 / פישרמן" },
        { key: "cost",        label: "עלות",           type: "text", value: item.cost || "" },
        { key: "paymentDue",  label: "תאריך תשלום",     type: "text", value: item.paymentDue || "" },
      ],
      onSubmit: (v) => {
        Store.set(s => ({
          ...s,
          stays: s.stays.map(st => st.id === id ? {
            ...st, destination: v.destination.trim(),
            note: (v.note || "").trim() || null,
            cost: (v.cost || "").trim() || item.cost,
            paymentDue: (v.paymentDue || "").trim() || null,
          } : st),
        }));
      },
    });
  },
  deleteStay(id) {
    const item = Store.state.stays.find(st => st.id === id);
    if (!item) return;
    Store.set(s => ({ ...s, stays: s.stays.filter(st => st.id !== id) }));
    if (window._haptic) window._haptic("warn");
    A.showUndoToast(`נמחק: ${item.destination}`, () => {
      Store.set(s => ({ ...s, stays: [...s.stays, item] }));
    });
  },

  // ---- Destinations --------------------------------------------------------
  addDestination() {
    A.openEditSheet({
      title: "יעד חדש",
      submitLabel: "הוסיפי",
      fields: [{ key: "name", label: "שם היעד", type: "text", required: true, autoFocus: true, placeholder: "למשל: בנגקוק" }],
      onSubmit: async (v) => {
        const name = v.name.trim();
        if (!name) return;
        // Try geocode automatically (free Nominatim)
        let coords = null;
        try {
          const results = await window.forwardGeocode?.(name);
          if (results && results[0]) coords = { lat: results[0].lat, lng: results[0].lng };
        } catch (e) { console.warn("[addDestination] geocode failed for", name, e); }
        Store.set(s => {
          const exists = s.trip.destinations.find(d => (typeof d === "string" ? d : d.name) === name);
          if (exists) return s;
          const entry = coords ? { name, lat: coords.lat, lng: coords.lng } : name;
          return {
            ...s,
            trip: { ...s.trip, destinations: [...s.trip.destinations, entry] },
            planningDest: s.planningDest || name,
          };
        });
      },
    });
  },
  removeDestination(name) {
    A.openConfirm({
      title: "להסיר יעד?",
      body: name,
      confirmLabel: "הסר",
      destructive: true,
      onConfirm: () => Store.set(s => {
        const destinations = s.trip.destinations.filter(d => (typeof d === "string" ? d : d.name) !== name);
        return { ...s, trip: { ...s.trip, destinations }, planningDest: s.planningDest === name ? (destinations[0] ? destName(destinations[0]) : "") : s.planningDest };
      }),
    });
  },
  setDestinationCoords(name, coords) {
    Store.set(s => ({
      ...s,
      trip: {
        ...s.trip,
        destinations: s.trip.destinations.map(d => {
          const dn = typeof d === "string" ? d : d.name;
          return dn === name ? { name: dn, lat: coords.lat, lng: coords.lng } : d;
        }),
      },
    }));
  },

  // ---- Stays ---------------------------------------------------------------
  addStay() {
    const dests = Store.state.trip.destinations;
    if (!dests.length) { A.showToast("הוסיפי קודם יעד לטיול", "error"); return; }
    A.openEditSheet({
      title: "לינה חדשה",
      submitLabel: "הוסיפי",
      fields: [
        { key: "destination", label: "יעד", type: "select", options: dests, value: dests[0], required: true, autoFocus: true },
        { key: "note",        label: "הערה / שם המלון", type: "text" },
        { key: "nights",      label: "מספר לילות", type: "number", value: "1" },
        { key: "cost",        label: "עלות",             type: "text", placeholder: "$200 / ₪700" },
        { key: "paymentDue",  label: "תאריך תשלום", type: "text", placeholder: "15.1.2027" },
      ],
      onSubmit: (v) => {
        const id = "s" + Date.now();
        const nights = parseInt(v.nights) || 1;
        const prevEnd = (Store.state.stays || []).reduce((max, st) => Math.max(max, st.endDayIdx || 0), 0);
        Store.set(s => ({
          ...s,
          stays: [...s.stays, {
            id, destination: v.destination, note: (v.note || "").trim() || null,
            nights, cost: (v.cost || "").trim() || "",
            paymentDue: (v.paymentDue || "").trim() || null,
            startDayIdx: prevEnd, endDayIdx: prevEnd + nights, paid: false,
          }],
        }));
      },
    });
  },

  // ---- Documents ---------------------------------------------------------
  async addDocumentScan(file, title) {
    try {
      const isPdf = file && file.type === "application/pdf";
      let ref;
      let kind = "image";
      if (isPdf) {
        // PDF path — don't try to resize through Image()/canvas. Store the raw
        // blob in MaMedia (IDB) and fall back to a dataURL on Safari-private.
        kind = "pdf";
        if (window.MaMedia && window.MaMedia.put) {
          try {
            ref = await window.MaMedia.put(file, { mime: "application/pdf", origName: file.name });
          } catch (e) {
            if (e && (e.name === "QuotaExceededError" || /quota/i.test(e.message || ""))) {
              A.showToast("אחסון מלא — חסר מקום למסמך", "error");
            }
            throw e;
          }
        } else {
          const r = new FileReader();
          ref = await new Promise((res, rej) => {
            r.onload = () => res(r.result);
            r.onerror = rej;
            r.readAsDataURL(file);
          });
        }
      } else {
        ref = await readAndResizePhoto(file, 2000, 0.88);
      }
      const id = "doc" + Date.now();
      Store.set(s => ({
        ...s,
        documents: [...(s.documents || []), {
          id, title: (title || file.name || "מסמך").replace(/\.\w+$/, ""),
          image: ref, kind, addedAt: new Date().toISOString().slice(0, 10),
          status: "ok",
        }],
      }));
      if (window._haptic) window._haptic("success");
      A.showToast(isPdf ? "PDF נוסף" : "מסמך נוסף", "info");
      if (A.scheduleMediaSync) A.scheduleMediaSync();
    } catch (e) {
      console.warn("doc scan failed:", e);
      A.showToast("נכשלה הטעינה", "error");
    }
  },
  async deleteDocument(id) {
    const item = (Store.state.documents || []).find(d => d.id === id);
    if (!item) return;
    Store.set(s => ({ ...s, documents: (s.documents || []).filter(d => d.id !== id) }));
    if (window._haptic) window._haptic("warn");
    A.showUndoToast(`נמחק: ${item.title}`, () => {
      Store.set(s => ({ ...s, documents: [...(s.documents || []), item] }));
    }, 5000, async () => {
      if (item.image && window.MaMedia && window.MaMedia.isRef(item.image)) {
        try { await window.MaMedia.del(item.image); } catch (e) { console.warn("[MaMedia.del] doc leak:", item.id, e); }
      }
    });
  },

  // ---- Voice note + bg music for memory draft ------------------------------
  // Audio now goes through IndexedDB (window.MaMedia) — same as photos.
  // Payload shape:
  //   { src: "idb://..." | <legacy dataUrl>, mime, sizeKb, durSec? }
  async setDraftVoiceNote(payload) {
    let toStore = payload;
    if (payload && payload.dataUrl && window.MaMedia && payload.dataUrl.startsWith("data:")) {
      try {
        const ref = await window.MaMedia.dataUrlToRef(payload.dataUrl);
        toStore = { src: ref, mime: payload.mime, sizeKb: payload.sizeKb, durSec: payload.durSec };
      } catch (e) {
        console.warn("[voiceNote] IDB save failed, keeping dataURL:", e);
        toStore = { src: payload.dataUrl, mime: payload.mime, sizeKb: payload.sizeKb, durSec: payload.durSec };
      }
    } else if (payload && payload.dataUrl) {
      toStore = { src: payload.dataUrl, mime: payload.mime, sizeKb: payload.sizeKb, durSec: payload.durSec };
    }
    Store.set(s => ({ ...s, draftMemory: { ...s.draftMemory, voiceNote: toStore } }));
  },
  async removeDraftVoiceNote() {
    const v = Store.state.draftMemory && Store.state.draftMemory.voiceNote;
    if (v && v.src && window.MaMedia && window.MaMedia.isRef(v.src)) {
      try { await window.MaMedia.del(v.src); } catch (e) { console.warn("[MaMedia.del] voice leak:", v && v.src, e); }
    }
    Store.set(s => ({ ...s, draftMemory: { ...s.draftMemory, voiceNote: null } }));
  },
  async setDraftBgMusic(input) {
    // Three payload shapes:
    //   { link: { service, embed, ... } }  - Spotify / Apple Music embed
    //   File                                - upload, blob → IDB ref
    //   already-resolved object             - passthrough
    if (input && input.link) {
      Store.set(s => ({ ...s, draftMemory: { ...s.draftMemory, bgMusic: { link: input.link } } }));
      return;
    }
    try {
      const data = await readFileAsDataUrl(input);
      if (data.sizeKb > 5120) { A.showToast("קובץ גדול מדי (מקסימום ~5MB)", "error"); return; }
      let stored;
      if (window.MaMedia && data.dataUrl.startsWith("data:")) {
        try {
          const ref = await window.MaMedia.dataUrlToRef(data.dataUrl);
          stored = { src: ref, mime: data.mime, name: data.name, sizeKb: data.sizeKb };
        } catch (e) {
          console.warn("[bgMusic] IDB save failed, keeping dataURL:", e);
          stored = { src: data.dataUrl, mime: data.mime, name: data.name, sizeKb: data.sizeKb };
        }
      } else {
        stored = { src: data.dataUrl, mime: data.mime, name: data.name, sizeKb: data.sizeKb };
      }
      Store.set(s => ({ ...s, draftMemory: { ...s.draftMemory, bgMusic: stored } }));
      A.showToast("שיר רקע נוסף", "info");
    } catch (e) {
      console.warn("[bgMusic] failed:", e);
      A.showToast("נכשלה הטעינה", "error");
    }
  },
  async removeDraftBgMusic() {
    const m = Store.state.draftMemory && Store.state.draftMemory.bgMusic;
    if (m && m.src && window.MaMedia && window.MaMedia.isRef(m.src)) {
      try { await window.MaMedia.del(m.src); } catch (e) { console.warn("[MaMedia.del] bgmusic leak:", m && m.src, e); }
    }
    Store.set(s => ({ ...s, draftMemory: { ...s.draftMemory, bgMusic: null } }));
  },

  // ─────────── Spots (Roamy-inspired) ───────────
  setSpotsList(name) { Store.set({ currentSpotsList: name }); },

  addSpotFromAnywhere() {
    A.openSheet({
      title: "הוספת מקום",
      options: [
        { label: "הדבקת קישור", action: () => A.addSpotFromLink() },
        { label: "חיפוש שם / כתובת", action: () => A.addSpotBySearch() },
        { label: "בחירה על מפה", action: () => A.addSpotFromMap() },
      ],
    });
  },

  addSpotFromLink() {
    const dests = (Store.state.trip.destinations || []).map(d => destName(d));
    const cats = (window.SPOT_CATEGORIES || []).map(c => c.label);
    A.openEditSheet({
      title: "הדבקת קישור",
      submitLabel: "הוסיפי",
      fields: [
        { key: "url",   label: "קישור (Google Maps / Apple Maps)", type: "text", required: true, autoFocus: true, placeholder: "הדביקי כאן..." },
        { key: "title", label: "שם המקום (אופציונלי)", type: "text" },
        { key: "cat",   label: "קטגוריה",  type: "select", options: cats, value: cats[0] || "אחר" },
        { key: "dest",  label: "יעד",       type: "select", options: ["—", ...dests], value: dests[0] || "—" },
      ],
      onSubmit: async (v) => {
        const url = (v.url || "").trim();
        if (!url) { A.showToast("חסר קישור", "error"); return; }
        if (window.isShortUrl(url)) {
          A.showToast("קישורים מקוצרים — פתחי בדפדפן ושלפי את המלא", "error");
          return;
        }
        const parsed = window.parseSpotUrl(url);
        let lat, lng, address = "";
        if (parsed) {
          lat = parsed.lat; lng = parsed.lng;
        } else {
          // Try forward-geocode from URL place name or raw text
          const name = window.extractPlaceNameFromUrl(url) || url;
          const results = await window.forwardGeocode(name);
          if (results && results[0]) { lat = results[0].lat; lng = results[0].lng; address = results[0].name; }
        }
        if (lat == null) { A.showToast("לא הצלחתי לחלץ מיקום", "error"); return; }
        const meta = (window.SPOT_CATEGORIES || []).find(c => c.label === v.cat) || (window.SPOT_CATEGORIES || [])[7];
        const title = (v.title || "").trim() || window.extractPlaceNameFromUrl(url) || `${lat.toFixed(3)}, ${lng.toFixed(3)}`;
        A.addSpotEntry({
          title, sub: "", lat, lng, address,
          cat: meta ? meta.key : "other",
          dest: v.dest && v.dest !== "—" ? v.dest : null,
          source: parsed ? "url" : "search",
          link: url,
        });
      },
    });
  },

  addSpotBySearch() {
    const dests = (Store.state.trip.destinations || []).map(d => destName(d));
    const cats = (window.SPOT_CATEGORIES || []).map(c => c.label);
    A.openEditSheet({
      title: "חיפוש מקום",
      submitLabel: "חפשי",
      fields: [
        { key: "q",     label: "שם המקום / כתובת", type: "text", required: true, autoFocus: true, placeholder: "למשל: Wat Pho Bangkok" },
        { key: "cat",   label: "קטגוריה",  type: "select", options: cats, value: cats[0] || "אחר" },
        { key: "dest",  label: "יעד",       type: "select", options: ["—", ...dests], value: dests[0] || "—" },
      ],
      onSubmit: async (v) => {
        const q = (v.q || "").trim();
        if (!q) return;
        // Loading toast so the user knows the slow Nominatim call is in flight
        // (was: sheet closed, silent freeze for 3-8s on cellular).
        A.showToast("מחפש מיקום...", "info");
        try {
          const results = await window.forwardGeocode(q);
          if (!results || !results[0]) { A.showToast("לא נמצא מיקום", "error"); return; }
          const r = results[0];
          const meta = (window.SPOT_CATEGORIES || []).find(c => c.label === v.cat) || (window.SPOT_CATEGORIES || [])[7];
          A.addSpotEntry({
            title: q, sub: "", lat: r.lat, lng: r.lng, address: r.name,
            cat: meta ? meta.key : "other",
            dest: v.dest && v.dest !== "—" ? v.dest : null,
            source: "search",
            link: null,
          });
        } catch (e) {
          console.warn("[addSpotBySearch] geocode failed:", e);
          A.showToast("חיפוש נכשל — בדקי חיבור", "error");
        }
      },
    });
  },

  addSpotFromMap() {
    // Reuse LocationPicker — render a transient picker via window flag
    Store.set({ spotMapPicker: { mode: "create" } });
  },
  closeSpotMapPicker() { Store.set({ spotMapPicker: null }); },

  // ---- Onboarding -------------------------------------------------------
  // Flush sync so the persisted "seen" flag survives even if the user closes
  // the tab the instant onboarding ends — otherwise the 300ms debounce window
  // could lose the write and onboarding would replay next boot.
  completeOnboarding() {
    Store.set(s => ({ ...s, seenOnboarding: true }));
    try { if (typeof flushPersist === "function") flushPersist(); } catch (e) {}
  },
  resetOnboarding() { Store.set(s => ({ ...s, seenOnboarding: false })); },

  // ---- Trip stats / export ----------------------------------------------
  async exportTripAsText() {
    const s = Store.state;
    const stats = window.computeTripStats ? window.computeTripStats(s) : null;
    if (!stats) { A.showToast("אין מספיק נתונים", "error"); return; }
    const trip = s.trip || {};
    const lines = [];
    lines.push(`${trip.name || "טיול"}`);
    if (stats.dateRange) lines.push(`📅 ${stats.dateRange}`);
    if (stats.daysCount) lines.push(`⏱  ${stats.daysCount} ימים`);
    if (stats.destNames.length) lines.push(`📍 ${stats.destNames.join(" · ")}`);
    lines.push("");
    lines.push(`✨ ${stats.memoriesCount} זיכרונות · ${stats.photosCount} תמונות`);
    lines.push(`🗺  ${stats.spotsCount} מקומות סומנו`);
    if (stats.totalSpent > 0) {
      lines.push(`💰 הוצאות: ${Math.round(stats.totalSpent)} ${trip.currency || "₪"} (${Math.round(stats.dailyAvg)} ליום)`);
    }
    if (stats.topMemory) {
      lines.push("");
      lines.push(`💛 הרגע הכי זכור: "${stats.topMemory.title}"`);
    }
    lines.push("");
    lines.push("— נשלח מ-MA-Trip");
    const text = lines.join("\n");
    try {
      if (window.MaNative && window.MaNative.share) {
        const r = await window.MaNative.share({ title: trip.name || "MA-Trip", text });
        if (r && r.ok) { A.showToast("שותף", "info"); return; }
      }
      if (navigator.clipboard) {
        await navigator.clipboard.writeText(text);
        A.showToast("הסיכום הועתק", "info");
      } else {
        A.showToast("שיתוף לא זמין", "error");
      }
    } catch (e) {
      console.warn("[exportTripAsText] failed:", e);
      A.showToast("שיתוף נכשל", "error");
    }
  },

  // ---- Legacy media migration -------------------------------------------
  // One-shot: scan memories + documents, move any inline dataURL to IDB blob,
  // replace with "idb://" ref. Frees up localStorage quota.
  // Each conversion is committed via a functional updater that re-reads
  // current state — so items added by the user during the async migration
  // are preserved instead of being clobbered by a stale snapshot.
  async migrateLegacyMedia() {
    if (!window.MaMedia) return;
    let migratedCount = 0;
    let migrationFailures = 0;

    const snapshot = Store.state;
    // Build flat work list. Item kinds: photo (memory.photos[i]), doc (document.image),
    // voice (memory.voiceNote.dataUrl), music (memory.bgMusic.dataUrl).
    const work = [];
    for (const m of (snapshot.memories || [])) {
      const photos = m.photos || [];
      for (let i = 0; i < photos.length; i++) {
        if (typeof photos[i] === "string" && photos[i].startsWith("data:")) {
          work.push({ kind: "memory", id: m.id, idx: i, dataUrl: photos[i] });
        }
      }
      if (m.voiceNote && typeof m.voiceNote.dataUrl === "string" && m.voiceNote.dataUrl.startsWith("data:")) {
        work.push({ kind: "voice", id: m.id, dataUrl: m.voiceNote.dataUrl });
      }
      if (m.bgMusic && typeof m.bgMusic.dataUrl === "string" && m.bgMusic.dataUrl.startsWith("data:")) {
        work.push({ kind: "music", id: m.id, dataUrl: m.bgMusic.dataUrl });
      }
    }
    for (const d of (snapshot.documents || [])) {
      if (typeof d.image === "string" && d.image.startsWith("data:")) {
        work.push({ kind: "doc", id: d.id, dataUrl: d.image });
      }
    }
    if (!work.length) return;

    for (const item of work) {
      try {
        const ref = await window.MaMedia.dataUrlToRef(item.dataUrl);
        // Functional updater: re-read current state, swap the specific entry.
        // Survives concurrent user mutations (new memories/documents added in flight).
        Store.set(s2 => {
          if (item.kind === "memory") {
            const memories = (s2.memories || []).map(m => {
              if (m.id !== item.id) return m;
              const photos = (m.photos || []).slice();
              // Only swap if the entry at idx is still the same dataURL we migrated.
              if (photos[item.idx] === item.dataUrl) photos[item.idx] = ref;
              return { ...m, photos };
            });
            return { ...s2, memories };
          } else if (item.kind === "voice") {
            const memories = (s2.memories || []).map(m => {
              if (m.id !== item.id || !m.voiceNote || m.voiceNote.dataUrl !== item.dataUrl) return m;
              return { ...m, voiceNote: { src: ref, mime: m.voiceNote.mime, sizeKb: m.voiceNote.sizeKb, durSec: m.voiceNote.durSec } };
            });
            return { ...s2, memories };
          } else if (item.kind === "music") {
            const memories = (s2.memories || []).map(m => {
              if (m.id !== item.id || !m.bgMusic || m.bgMusic.dataUrl !== item.dataUrl) return m;
              return { ...m, bgMusic: { src: ref, mime: m.bgMusic.mime, name: m.bgMusic.name, sizeKb: m.bgMusic.sizeKb } };
            });
            return { ...s2, memories };
          } else {
            const documents = (s2.documents || []).map(d => {
              if (d.id !== item.id || d.image !== item.dataUrl) return d;
              return { ...d, image: ref };
            });
            return { ...s2, documents };
          }
        });
        migratedCount++;
      } catch (e) {
        migrationFailures++;
        console.warn("[MaMedia] migration item failed:", item.kind, item.id, e);
      }
    }

    if (migrationFailures > 0) {
      try { A.showToast(`הגירת מדיה: ${migrationFailures} פריטים נכשלו`, "error"); } catch (_) {}
    }
  },

  addSpotEntry(input) {
    const id = "sp" + Date.now();
    const createdAt = new Date().toISOString().slice(0, 10);
    Store.set(s => ({
      ...s,
      spots: [{ id, color: null, createdAt, ...input }, ...s.spots],
    }));
    A.showToast("מקום נוסף", "info");
  },

  editSpot(id) {
    const item = Store.state.spots.find(sp => sp.id === id);
    if (!item) return;
    const dests = (Store.state.trip.destinations || []).map(d => destName(d));
    const cats = (window.SPOT_CATEGORIES || []).map(c => c.label);
    const curCatLabel = ((window.SPOT_CATEGORIES || []).find(c => c.key === item.cat) || {}).label || cats[0];
    A.openEditSheet({
      title: "עריכת מקום",
      submitLabel: "שמרי",
      fields: [
        { key: "title", label: "שם המקום", type: "text", value: item.title, required: true, autoFocus: true },
        { key: "sub",   label: "תיאור / הערה", type: "text", value: item.sub || "" },
        { key: "cat",   label: "קטגוריה",  type: "select", options: cats, value: curCatLabel },
        { key: "dest",  label: "יעד",       type: "select", options: ["—", ...dests], value: item.dest || "—" },
      ],
      onSubmit: (v) => {
        const meta = (window.SPOT_CATEGORIES || []).find(c => c.label === v.cat) || (window.SPOT_CATEGORIES || [])[7];
        Store.set(s => ({
          ...s,
          spots: s.spots.map(sp => sp.id === id ? {
            ...sp,
            title: v.title.trim(),
            sub: v.sub || "",
            cat: meta ? meta.key : sp.cat,
            dest: v.dest && v.dest !== "—" ? v.dest : null,
          } : sp),
        }));
      },
    });
  },

  deleteSpot(id) {
    const item = Store.state.spots.find(sp => sp.id === id);
    if (!item) return;
    Store.set(s => ({ ...s, spots: s.spots.filter(sp => sp.id !== id) }));
    if (window._haptic) window._haptic("warn");
    A.showUndoToast(`נמחק: ${item.title}`, () => {
      Store.set(s => ({ ...s, spots: [item, ...s.spots] }));
    });
  },

  askSpotActions(id) {
    const item = Store.state.spots.find(sp => sp.id === id);
    if (!item) return;
    A.openSheet({
      title: item.title,
      options: [
        { label: "פתיחה ב-Google Maps", action: () => {
          const url = `https://www.google.com/maps/search/?api=1&query=${item.lat},${item.lng}`;
          window.open(url, "_blank", "noopener");
        } },
        { label: "הוסף לרשימה", action: () => A.openSpotListSheet(id) },
        { label: "ערוך", action: () => A.editSpot(id) },
        { label: "מחק", destructive: true, action: () => A._confirmDelete(item.title, () => A.deleteSpot(id)) },
      ],
    });
  },

  // ─────────── AI Itinerary Generator ───────────
  confirmGenerateItinerary() {
    const spotCount = (Store.state.spots || []).length;
    const days = Store.state.trip.days;
    if (!spotCount) { A.showToast("אין מקומות שמורים", "error"); return; }
    if (!days) { A.showToast("הגדירי קודם מספר ימים לטיול", "error"); return; }
    A.openConfirm({
      title: "לבנות מסלול אוטומטי?",
      body: `יווצרו פעילויות מ-${spotCount} מקומות לאורך ${days} ימים. פעילויות שהוספת ידנית יישמרו; הצעות אוטומטיות קודמות יוחלפו.`,
      confirmLabel: "בנה",
      onConfirm: () => A.generateItinerary(),
    });
  },

  generateItinerary() {
    const s = Store.state;
    const days = s.trip.days;
    const spots = (s.spots || []).filter(sp => sp.lat != null && sp.lng != null);
    if (!spots.length || !days) return;
    // Drop previously generated auto-spot activities so regen replaces instead of duplicating
    Store.set(st => ({ ...st, activities: (st.activities || []).filter(a => a.type !== "auto-spot") }));
    const destinations = (s.trip.destinations || []).map(d => destName(d));
    const stays = s.stays || [];

    // Day → destination map (use stays if defined; else split evenly across destinations)
    const dayToDest = new Array(days).fill(null);
    if (stays.length) {
      stays.forEach(st => {
        for (let i = st.startDayIdx; i < st.endDayIdx && i < days; i++) dayToDest[i] = st.destination;
      });
    }
    // Fill blanks evenly across destinations
    if (dayToDest.some(d => !d)) {
      const destPool = destinations.length ? destinations : ["", ""];
      const perDest = Math.ceil(days / destPool.length);
      let idx = 0;
      for (let i = 0; i < days; i++) {
        if (!dayToDest[i]) dayToDest[i] = destPool[Math.min(Math.floor(idx / perDest), destPool.length - 1)] || null;
        idx++;
      }
    }

    // Greedy nearest-neighbor ordering per destination, then bucket into days
    const newActs = [];
    const groupBy = (list, key) => list.reduce((acc, x) => ((acc[x[key] || "_"] ||= []).push(x), acc), {});
    const spotsByDest = groupBy(spots, "dest");
    // Spots without dest → distribute across all days
    const orphanSpots = spotsByDest["_"] || [];
    delete spotsByDest["_"];

    function dist(a, b) {
      const dx = a.lat - b.lat, dy = a.lng - b.lng;
      return Math.sqrt(dx * dx + dy * dy);
    }
    function nearestOrder(list) {
      if (!list.length) return [];
      const remaining = list.slice();
      const out = [remaining.shift()];
      while (remaining.length) {
        const last = out[out.length - 1];
        let best = 0, bestD = Infinity;
        remaining.forEach((sp, i) => {
          const d = dist(last, sp);
          if (d < bestD) { bestD = d; best = i; }
        });
        out.push(remaining.splice(best, 1)[0]);
      }
      return out;
    }

    Object.entries(spotsByDest).forEach(([dest, list]) => {
      const orderedSpots = nearestOrder(list);
      const dayIdxs = dayToDest.map((d, i) => d === dest ? i : -1).filter(i => i >= 0);
      if (!dayIdxs.length) return;
      const perDay = Math.ceil(orderedSpots.length / dayIdxs.length);
      orderedSpots.forEach((sp, i) => {
        const di = dayIdxs[Math.min(Math.floor(i / perDay), dayIdxs.length - 1)];
        const slot = i % perDay;
        const startHour = 9 + slot * 2;
        const time = `${String(startHour).padStart(2, "0")}:00`;
        newActs.push({
          id: "a" + Date.now() + "_" + Math.random().toString(36).slice(2, 6),
          day: di, time,
          title: sp.title,
          loc: sp.address || sp.sub || "",
          note: null,
          type: "auto-spot",
          spotId: sp.id,
          hours: { state: "flex", value: time },
        });
      });
    });

    if (orphanSpots.length) {
      const ordered = nearestOrder(orphanSpots);
      ordered.forEach((sp, i) => {
        const di = i % days;
        const slot = Math.floor(i / days);
        const time = `${String(11 + slot).padStart(2, "0")}:00`;
        newActs.push({
          id: "a" + Date.now() + "_" + Math.random().toString(36).slice(2, 6),
          day: di, time,
          title: sp.title,
          loc: sp.address || sp.sub || "",
          note: null,
          type: "auto-spot",
          spotId: sp.id,
          hours: { state: "flex", value: time },
        });
      });
    }

    Store.set(st => ({ ...st, activities: [...st.activities, ...newActs] }));
    A.showToast(`נוספו ${newActs.length} פעילויות`, "info");
  },

  // ─────────── Lists (named collections of spots) ───────────
  addList() {
    A.openEditSheet({
      title: "רשימה חדשה",
      submitLabel: "צרי",
      fields: [
        { key: "name", label: "שם הרשימה", type: "text", required: true, autoFocus: true, placeholder: "למשל: אוכל בבנגקוק" },
      ],
      onSubmit: (v) => {
        const name = (v.name || "").trim();
        if (!name) return;
        const id = "ls" + Date.now();
        Store.set(s => ({
          ...s,
          lists: [...s.lists, { id, name, color: null, createdAt: new Date().toISOString().slice(0, 10) }],
        }));
      },
    });
  },
  editList(id) {
    const item = Store.state.lists.find(l => l.id === id);
    if (!item) return;
    A.openEditSheet({
      title: "עריכת רשימה",
      submitLabel: "שמרי",
      fields: [{ key: "name", label: "שם הרשימה", type: "text", value: item.name, required: true, autoFocus: true }],
      onSubmit: (v) => {
        Store.set(s => ({
          ...s,
          lists: s.lists.map(l => l.id === id ? { ...l, name: v.name.trim() } : l),
        }));
      },
    });
  },
  deleteList(id) {
    Store.set(s => ({
      ...s,
      lists: s.lists.filter(l => l.id !== id),
      spots: s.spots.map(sp => ({ ...sp, lists: (sp.lists || []).filter(x => x !== id) })),
      currentSpotsListId: s.currentSpotsListId === id ? null : s.currentSpotsListId,
    }));
  },
  askListActions(id) {
    const item = Store.state.lists.find(l => l.id === id);
    if (!item) return;
    A.openSheet({
      title: item.name,
      options: [
        { label: "סנן רשימה זו", action: () => A.setSpotsListById(id) },
        { label: "ערוך שם", action: () => A.editList(id) },
        { label: "מחק רשימה", destructive: true, action: () => A._confirmDelete(item.name, () => A.deleteList(id)) },
      ],
    });
  },
  setSpotsListById(id) {
    Store.set({ currentSpotsListId: id });
  },
  toggleSpotInList(spotId, listId) {
    Store.set(s => ({
      ...s,
      spots: s.spots.map(sp => sp.id === spotId
        ? { ...sp, lists: (sp.lists || []).includes(listId)
            ? (sp.lists || []).filter(x => x !== listId)
            : [...(sp.lists || []), listId] }
        : sp),
    }));
  },
  openSpotListSheet(spotId) {
    const item = Store.state.spots.find(sp => sp.id === spotId);
    const lists = Store.state.lists;
    if (!item) return;
    if (!lists.length) {
      A.openConfirm({
        title: "אין רשימות עדיין",
        body: "ליצור רשימה ראשונה?",
        confirmLabel: "צור",
        onConfirm: () => A.addList(),
      });
      return;
    }
    A.openSheet({
      title: "הוסף לרשימות",
      options: [
        ...lists.map(l => ({
          label: ((item.lists || []).includes(l.id) ? "✓ " : "") + l.name,
          action: () => A.toggleSpotInList(spotId, l.id),
        })),
        { label: "+ רשימה חדשה", action: () => A.addList() },
      ],
    });
  },

  // ─────────── Trip Share / Import ───────────
  shareTrip() {
    try {
      const s = Store.state;
      const payload = {
        v: 2, kind: "ma-trip",
        trip: s.trip, stays: s.stays, spots: s.spots, lists: s.lists,
        activities: s.activities, reminders: s.reminders, packing: s.packing,
        packingCategories: s.packingCategories, expenses: s.expenses,
        memories: (s.memories || []).map(m => ({
          ...m,
          photos: [],
          voiceNote: m.voiceNote && m.voiceNote.link ? m.voiceNote : null,
          bgMusic: m.bgMusic && m.bgMusic.link ? m.bgMusic : null,
        })),
        documents: [],
        // Strip every credential-shaped field before exporting. Anyone who
        // receives this share URL is untrusted; they only need profile chrome
        // (id, name, color) — never password hash / salt / tokens / sessions.
        accounts: (s.accounts || []).map(a => ({
          id: a.id, name: a.name, color: a.color,
          // email intentionally omitted — share recipient doesn't need it
        })),
      };
      const json = JSON.stringify(payload);
      // TextEncoder-based UTF-8 -> base64. The previous
      // btoa(unescape(encodeURIComponent(...))) form relies on deprecated
      // `unescape` which is gone in strict-mode bundlers and missing on some
      // non-browser runtimes. Modern equivalent works everywhere targets land.
      const bytes = new TextEncoder().encode(json);
      let bin = "";
      for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]);
      const b64 = btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
      const url = `${location.origin}${location.pathname}#trip=${b64}`;
      const copy = () => {
        if (navigator.clipboard) {
          navigator.clipboard.writeText(url).then(
            () => A.showToast("קישור הועתק", "info"),
            () => A.showToast("העתקה נכשלה — העתיקי ידנית", "error"),
          );
        } else {
          A.showToast("דפדפן לא תומך בהעתקה", "error");
        }
      };
      copy();
      A.openConfirm({
        title: "קישור שיתוף נוצר",
        body: "ההזמנה נשלחת בקישור. תמונות לא נכללות (גודל). המקבל יפתח ויסונכרן אוטומטית.",
        confirmLabel: "העתק קישור",
        cancelLabel: "סגור",
        onConfirm: copy,
      });
    } catch (e) { A.showToast("השיתוף נכשל", "error"); }
  },
  // ─────────── Accounts / Auth (local-first; will swap to backend) ───────────
  async _hashPassword(password, salt) {
    const enc = new TextEncoder();
    const baseKey = await crypto.subtle.importKey("raw", enc.encode(password), { name: "PBKDF2" }, false, ["deriveBits"]);
    const bits = await crypto.subtle.deriveBits(
      { name: "PBKDF2", salt: enc.encode(salt), iterations: 100000, hash: "SHA-256" },
      baseKey, 256,
    );
    return Array.from(new Uint8Array(bits)).map(b => b.toString(16).padStart(2, "0")).join("");
  },
  _randomSalt() {
    const a = new Uint8Array(16);
    crypto.getRandomValues(a);
    return Array.from(a).map(b => b.toString(16).padStart(2, "0")).join("");
  },
  async signup({ name, email, password }) {
    const n = (name || "").trim();
    const e = (email || "").trim().toLowerCase();
    if (!n) { A.showToast("חסר שם", "error"); return null; }
    if (!/^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/.test(e)) { A.showToast("מייל לא תקין", "error"); return null; }
    if (!password || password.length < 8) { A.showToast("סיסמה חייבת 8+ תווים", "error"); return null; }
    const dup = (Store.state.accounts || []).find(u => (u.email || "").toLowerCase() === e);
    if (dup) { A.showToast("כתובת מייל כבר רשומה", "error"); return null; }
    const salt = A._randomSalt();
    const passwordHash = await A._hashPassword(password, salt);
    // 128-bit random id — avoids the timing-enumerable Date.now() prefix that
    // a `+ Math.random().toString(36).slice(2,6)` (~20 bits) tail couldn't fix.
    const idBytes = new Uint8Array(16);
    crypto.getRandomValues(idBytes);
    const id = "u_" + Array.from(idBytes).map(b => b.toString(16).padStart(2, "0")).join("");
    const palette = ["#ff4e64", "#0A66D6", "#34c759", "#ff9500", "#af52de", "#30b0c7"];
    // Two-shape account: full record kept ONLY in a non-persisted in-memory
    // map (window._maTripCreds) for password verification this session; the
    // store accounts array carries no hash at all so a Store.state dump (or
    // an accidental sync) cannot exfiltrate password material.
    const safeUser = {
      id, name: n, email: e, color: palette[(Store.state.accounts || []).length % palette.length],
      createdAt: Date.now(),
    };
    window._maTripCreds = window._maTripCreds || {};
    window._maTripCreds[id] = { salt, passwordHash };
    Store.set(s => ({ ...s, accounts: [...(s.accounts || []), safeUser], currentUserId: id }));
    A.showToast(`ברוכ/ה הבא/ה, ${n}`, "info");
    return safeUser;
  },
  _ctEq(a, b) {
    if (typeof a !== "string" || typeof b !== "string") return false;
    if (a.length !== b.length) return false;
    let diff = 0;
    for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
    return diff === 0;
  },
  async login({ email, password }) {
    const e = (email || "").trim().toLowerCase();
    const u = (Store.state.accounts || []).find(x => (x.email || "").toLowerCase() === e);
    if (!u) { A.showToast("מייל או סיסמה שגויים", "error"); return false; }
    const creds = (window._maTripCreds || {})[u.id];
    if (!creds || !creds.passwordHash || !creds.salt) { A.showToast("מייל או סיסמה שגויים", "error"); return false; }
    const hash = await A._hashPassword(password || "", creds.salt);
    if (!A._ctEq(hash, creds.passwordHash)) { A.showToast("מייל או סיסמה שגויים", "error"); return false; }
    Store.set({ currentUserId: u.id });
    A.showToast(`התחברת כ-${u.name}`, "info");
    return true;
  },
  // loginAs(id) was a silent identity-switch — XSS could call it to impersonate
  // any local account. Replaced with a password-prompted flow.
  loginAsPrompt(id) {
    const u = (Store.state.accounts || []).find(x => x.id === id);
    if (!u) { A.showToast("חשבון לא נמצא", "error"); return; }
    A.openEditSheet({
      title: `התחברות כ-${u.name}`,
      submitLabel: "התחברי",
      fields: [
        { key: "password", label: "סיסמה", type: "password", required: true, autoFocus: true },
      ],
      onSubmit: async (v) => { await A.login({ email: u.email, password: v.password }); },
    });
  },
  async logout() {
    try { if (window.MaApi) await window.MaApi.logout(); }
    catch (e) { console.warn("[logout] server logout failed:", e); }
    // Clear ALL per-user state so the next user signing in on the same device
    // doesn't inherit the previous user's serverTripId/serverVersion. Without
    // this, the next mutation tries to PUT to a trip the new user doesn't
    // own → 403 forbidden → silent failure → cross-user data contamination.
    Store.set(s => {
      const cleared = { ...s, currentUserId: null, serverTripId: null, serverVersion: 0, r2Refs: {}, lastSyncAt: 0 };
      for (const k of TRIP_DATA_KEYS) {
        const init = INITIAL[k];
        cleared[k] = Array.isArray(init) ? [] : (init && typeof init === "object" ? { ...init } : init);
      }
      return cleared;
    });
  },
  openAuthFlow() {
    const accounts = Store.state.accounts || [];
    if (!accounts.length) { A.signupSheet(); return; }
    A.openSheet({
      title: "בחר/י חשבון",
      options: [
        ...accounts.map(u => ({ label: u.name, action: () => A.loginAsPrompt(u.id) })),
        { label: "+ חשבון חדש", action: () => A.signupSheet() },
      ],
    });
  },
  signupSheet() {
    A.openEditSheet({
      title: "חשבון חדש",
      submitLabel: "צרי",
      fields: [
        { key: "name",     label: "שם להצגה",     type: "text",     required: true, autoFocus: true, placeholder: "השם הפרטי שלך" },
        { key: "email",    label: "אימייל",        type: "text",     required: true, placeholder: "you@example.com" },
        { key: "password", label: "סיסמה (8+ תווים)", type: "password", required: true },
      ],
      onSubmit: (v) => A.signup({ name: v.name, email: v.email, password: v.password }),
    });
  },
  quickAddSheet() {
    A.openSheet({
      title: "הוספה מהירה",
      options: [
        { label: "💸 הוצאה", action: () => A.addExpense() },
        { label: "📍 מקום שמור", action: () => A.addSpotFromAnywhere() },
        { label: "🔔 תזכורת", action: () => A.addReminder() },
        { label: "🎒 פריט אריזה", action: () => A.addPackingFAB() },
        { label: "📸 זיכרון", action: () => { const cb = window.A._navAdd; if (cb) cb(); else A.showToast("עברי ל-זיכרונות → +", "info"); } },
      ],
    });
  },

  openProfile() {
    const u = currentUser();
    A.openSheet({
      title: u ? `מחובר/ת: ${u.name}` : "חשבון",
      options: [
        { label: "📥 ייבוא טיול מקישור", action: () => A.importFromLinkPrompt() },
        { label: "החלפת משתמש", action: () => A.openAuthFlow() },
        { label: "התנתקות", destructive: true, action: () => A.logout() },
      ],
    });
  },

  importFromLinkPrompt() {
    A.openEditSheet({
      title: "ייבוא טיול",
      submitLabel: "ייבאי",
      fields: [
        { key: "url", label: "הדבק/י קישור שיתוף", type: "text", required: true, autoFocus: true, placeholder: "https://...#trip=..." },
      ],
      onSubmit: (v) => {
        const url = (v.url || "").trim();
        const m = url.match(/#trip=([^&]+)/);
        if (!m) { A.showToast("קישור לא תקין", "error"); return; }
        history.replaceState(null, "", location.pathname + "#trip=" + m[1]);
        A.tryImportFromHash();
      },
    });
  },

  tryImportFromHash() {
    if (!location.hash || !/^#trip=/.test(location.hash)) return;
    const b64 = location.hash.replace(/^#trip=/, "");
    // Hard cap on share payload size — attacker URL with 10MB blob would
    // exhaust localStorage on first persist (causing all subsequent writes to
    // silently fail). 200KB raw base64 ≈ 150KB JSON ≈ realistic trip.
    if (b64.length > 200000) {
      A.showToast("קישור גדול מדי", "error");
      return;
    }
    try {
      const fixed = b64.replace(/-/g, "+").replace(/_/g, "/");
      // Inverse of shareTrip's encoder. atob -> binary string -> Uint8Array
      // -> TextDecoder. Drops deprecated `escape` global.
      const bin = atob(fixed);
      const bytes = new Uint8Array(bin.length);
      for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
      const json = new TextDecoder().decode(bytes);
      const payload = JSON.parse(json);
      if (!payload || payload.kind !== "ma-trip") {
        A.showToast("קישור לא תקין — לא נמצאו נתוני טיול", "error");
        return;
      }
      // Schema validation. Reject non-array collections so mergeById can't
      // crash mid-Store.set (after persist has already run, leaving corrupt
      // localStorage state).
      const arr = (v) => Array.isArray(v) ? v.slice(0, 1000) : [];
      const obj = (v) => (v && typeof v === "object" && !Array.isArray(v)) ? v : null;
      const safeTrip = obj(payload.trip);

      A.openConfirm({
        title: "קישור שיתוף נמצא",
        body: `למזג טיול "${(safeTrip && safeTrip.name) || "ללא שם"}"? עדכונים חדשים מהשולח יחליפו ישנים אצלך (ולהפך).`,
        confirmLabel: "מזג",
        onConfirm: () => {
          function mergeById(localList, remoteList) {
            const map = new Map();
            (localList || []).forEach(it => it && it.id && map.set(it.id, it));
            (remoteList || []).forEach(it => {
              if (!it || !it.id || typeof it.id !== "string") return;
              const local = map.get(it.id);
              if (!local) { map.set(it.id, it); return; }
              const lu = local.updatedAt || 0;
              const ru = it.updatedAt || 0;
              map.set(it.id, ru >= lu ? it : local);
            });
            return Array.from(map.values());
          }
          Store.set(s => {
            // Account import allowlist — only profile chrome, never credentials,
            // and never overwrite an existing account id (prevents impersonation).
            const accounts = [...(s.accounts || [])];
            const existingIds = new Set(accounts.map(a => a.id));
            arr(payload.accounts).forEach(u => {
              if (!u || typeof u.id !== "string" || existingIds.has(u.id)) return;
              accounts.push({
                id: u.id,
                name: typeof u.name === "string" ? u.name.slice(0, 64) : "אורח",
                color: typeof u.color === "string" ? u.color.slice(0, 16) : "#FF4E64",
              });
            });
            const tripLU = (s.trip && s.trip.updatedAt) || 0;
            const tripRU = (safeTrip && safeTrip.updatedAt) || 0;
            return {
              ...s,
              trip: safeTrip && tripRU >= tripLU ? safeTrip : (s.trip || safeTrip || s.trip),
              stays: mergeById(s.stays, arr(payload.stays)),
              spots: mergeById(s.spots, arr(payload.spots)),
              lists: mergeById(s.lists, arr(payload.lists)),
              activities: mergeById(s.activities, arr(payload.activities)),
              reminders: mergeById(s.reminders, arr(payload.reminders)),
              packing: mergeById(s.packing, arr(payload.packing)),
              packingCategories: [...new Set([
                ...(s.packingCategories || []),
                ...arr(payload.packingCategories).filter(c => typeof c === "string").map(c => c.slice(0, 64)),
              ])],
              expenses: mergeById(s.expenses, arr(payload.expenses)),
              memories: mergeById(s.memories, arr(payload.memories)),
              documents: mergeById(s.documents, arr(payload.documents)),
              accounts,
            };
          });
          history.replaceState(null, "", location.pathname);
          A.showToast("טיול מוזג בהצלחה", "info");
        },
      });
    } catch (e) {
      console.warn("[tryImportFromHash]", e);
      A.showToast("קישור פגום", "error");
    }
  },
};

// Expose audio helpers to UI (called by recorder + bg-music picker in screens.jsx)
window._recordAudio = recordAudio;
window._readFileAsDataUrl = readFileAsDataUrl;

// ───────────────── Backend sync (preview, opt-in) ─────────────────
// Default OFF — flip via `A.setSyncEnabled(true)` or `window.MA_SYNC_ENABLED = true`.
// Pushes a debounced PUT of trip data to the Cloudflare Worker after each change.
// Pulls on mount (via A.pullSync). Conflict (409) → refetch and overwrite local.
let _syncTimer = null;
let _syncing = false;
const SYNC_DEBOUNCE_MS = 3000;
const TRIP_DATA_KEYS = ["trip","stays","spots","lists","activities","reminders","packing","packingCategories","expenses","memories","documents"];

function syncEnabled() {
  return !!(window.MA_SYNC_ENABLED || (Store.state && Store.state.syncEnabled));
}
function buildSyncPayload() {
  const out = {};
  for (const k of TRIP_DATA_KEYS) out[k] = Store.state[k];
  return out;
}
function scheduleSync() {
  if (!syncEnabled() || !Store.state.currentUserId) return;
  if (_syncTimer) clearTimeout(_syncTimer);
  _syncTimer = setTimeout(() => runSync().catch(e => console.warn("[sync] push failed:", e)), SYNC_DEBOUNCE_MS);
}
async function runSync() {
  _syncTimer = null;
  if (!syncEnabled() || _syncing || !window.MaApi || !Store.state.currentUserId) return;
  _syncing = true;
  try {
    const data = buildSyncPayload();
    if (!Store.state.serverTripId) {
      const r = await window.MaApi.createTrip(data);
      if (r && r.id) Store.set(s => ({ ...s, serverTripId: r.id, serverVersion: r.version || 0, lastSyncAt: Date.now() }));
    } else {
      const r = await window.MaApi.updateTrip(Store.state.serverTripId, data, Store.state.serverVersion || 0);
      if (r && r.version != null) {
        // Use Store.set so notify() fires — UI bound to lastSyncAt/serverVersion
        // will update. Direct _state mutation bypassed subscribers.
        Store.set(s => ({ ...s, serverVersion: r.version, lastSyncAt: Date.now() }));
      }
    }
  } catch (e) {
    // 409 = stale base version, 400/missing-expected-version = we lost track
    // of the server version entirely (e.g. localStorage cleared after first
    // sync). Both require a pull-then-merge cycle.
    const needsPull = e && Store.state.serverTripId && (
      e.status === 409 ||
      (e.status === 400 && e.code === "missing-expected-version")
    );
    if (needsPull) {
      try {
        // Worker returns { trip: { data, version, ... }, members }. Earlier code
        // read fresh.data / fresh.version directly → both undefined → the
        // conflict-resolution path silently no-op'd, leaving the client stuck
        // in an infinite 409 loop while showing "synced" toasts.
        const fresh = await window.MaApi.getTrip(Store.state.serverTripId);
        const trip = fresh && fresh.trip;
        if (trip && trip.data) {
          Store.set(s => ({ ...s, ..._serverDataPatch(trip.data), serverVersion: trip.version || 0, lastSyncAt: Date.now() }));
          A.showToast("הנתונים סונכרו עם השרת", "info");
        }
      } catch (e2) { console.warn("[sync] conflict pull failed:", e2); }
    } else if (e && e.status !== 401) {
      console.warn("[sync] push failed:", e);
    }
  } finally {
    _syncing = false;
  }
}
// Whitelist: only fields the server is allowed to write back into client
// state. Without this, a spread of `fresh.data` at the top level could
// overwrite ephemeral UI keys (actionSheet/toast/editSheet) and break an
// in-flight interaction. Matches TRIP_DATA_KEYS exactly.
function _serverDataPatch(data) {
  const out = {};
  for (const k of TRIP_DATA_KEYS) if (k in data) out[k] = data[k];
  return out;
}
// After login, attach the user's existing server-side trip (if any) instead of
// blindly creating a new one on the next mutation. Without this, every fresh
// device → new trip row, duplicating server state and never seeing the
// partner's edits.
A.attachServerTrip = async function() {
  if (!window.MaApi || !Store.state.currentUserId) return;
  if (Store.state.serverTripId) return; // already attached
  try {
    const r = await window.MaApi.listTrips();
    const trips = (r && r.trips) || [];
    if (!trips.length) return; // brand-new account; createTrip on next mutation
    // Pick the most recently updated (server orders DESC by updated_at).
    const newest = trips[0];
    Store.set(s => ({ ...s, serverTripId: newest.id, serverVersion: newest.version || 0 }));
    await A.pullSync();
  } catch (e) {
    if (e && e.status !== 401) console.warn("[attachServerTrip] failed:", e);
  }
};

A.pullSync = async function() {
  if (!syncEnabled() || !window.MaApi || !Store.state.currentUserId || !Store.state.serverTripId) return;
  try {
    // Worker returns { trip: { data, version }, members }. Unwrap correctly —
    // see runSync conflict catch above for the same bug.
    const fresh = await window.MaApi.getTrip(Store.state.serverTripId);
    const trip = fresh && fresh.trip;
    if (trip && trip.data) {
      Store.set(s => ({ ...s, ..._serverDataPatch(trip.data), serverVersion: trip.version || 0, lastSyncAt: Date.now() }));
    }
  } catch (e) {
    if (e && e.status !== 401 && e && e.status !== 404) console.warn("[sync] pull failed:", e);
  }
};
A.setSyncEnabled = function(on) {
  Store.set(s => ({ ...s, syncEnabled: !!on }));
  if (on) {
    A.pullSync()
      .catch(e => console.warn("[setSyncEnabled] initial pull failed:", e))
      .finally(() => scheduleSync());
  }
};
A.syncStatus = function() {
  return {
    enabled: syncEnabled(),
    tripId: Store.state.serverTripId || null,
    version: Store.state.serverVersion || 0,
    lastSyncAt: Store.state.lastSyncAt || 0,
    pendingMedia: A.mediaSyncStatus().pending,
    uploadedMedia: A.mediaSyncStatus().uploaded,
  };
};

// ───────────────── Media R2 sync ─────────────────
// Per blob lifecycle:
//   1. User adds photo/voice/doc → MaMedia.put encrypts client-side and writes
//      to IDB. Ref shape: "idb://<id>".
//   2. When sync is enabled + signed in + serverTripId exists, the queue
//      lifts each NEW IDB ref, decrypts client-side, base64-encodes raw bytes,
//      and POSTs to /api/trips/:tripId/blobs. Worker AES-GCM re-encrypts with
//      ENCRYPTION_KEY before R2 put. HTTPS protects transit.
//   3. Local state.r2Refs maps idb:// → remote blob id so we don't re-upload.
//   4. On a different device, MediaImg falls back to MaApi.downloadBlob(id)
//      when the local IDB ref is missing — populates the local cache too.
function _bytesToB64Url(bytes) {
  let s = "";
  const a = new Uint8Array(bytes);
  for (let i = 0; i < a.length; i++) s += String.fromCharCode(a[i]);
  return btoa(s).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}

let _mediaUploading = false;
// Map<idbRef, retryAfterEpochMs>. Used to be a permanent Set, which meant a
// single transient R2 5xx blacklisted a photo for the rest of the session
// (and a localStorage wipe would just clear it without ever retrying).
// Now: 5-minute backoff per ref, so flaky LTE retries naturally.
const _mediaFailed = new Map();
const MEDIA_FAILED_TTL_MS = 5 * 60 * 1000;
function _isMediaBlacklisted(ref) {
  const exp = _mediaFailed.get(ref);
  if (exp == null) return false;
  if (Date.now() >= exp) { _mediaFailed.delete(ref); return false; }
  return true;
}
const ALLOWED_KINDS = new Set(["photo", "voice", "document"]);

A.mediaSyncStatus = function() {
  const r2 = Store.state.r2Refs || {};
  // Count only live (non-expired) blacklisted entries.
  let failed = 0;
  for (const ref of _mediaFailed.keys()) if (_isMediaBlacklisted(ref)) failed++;
  return {
    uploaded: Object.keys(r2).length,
    pending: A._collectPendingMediaRefs().length,
    failed,
  };
};

A._collectPendingMediaRefs = function() {
  const r2 = Store.state.r2Refs || {};
  const out = [];
  const seen = new Set();
  const addRef = (ref, kind) => {
    if (!ref || typeof ref !== "string" || !ref.startsWith("idb://")) return;
    if (r2[ref] || seen.has(ref) || _isMediaBlacklisted(ref)) return;
    seen.add(ref);
    out.push({ ref, kind });
  };
  // Memories: photos[] + voiceNote + bgMusic.audioRef
  (Store.state.memories || []).forEach(m => {
    (m.photos || []).forEach(p => addRef(p, "photo"));
    if (m.voiceNote && m.voiceNote.ref) addRef(m.voiceNote.ref, "voice");
  });
  // Documents
  (Store.state.documents || []).forEach(d => addRef(d.image, "document"));
  return out;
};

A.uploadMediaToR2 = async function(idbRef, kind, mime) {
  if (!ALLOWED_KINDS.has(kind)) throw new Error("bad-kind:" + kind);
  if (!window.MaApi || !window.MaMedia) throw new Error("api-or-media-missing");
  if (!Store.state.serverTripId) throw new Error("no-server-trip-id");
  const blob = await window.MaMedia.getBlob(idbRef);
  if (!blob) throw new Error("blob-missing:" + idbRef);
  const buf = await blob.arrayBuffer();
  const body = _bytesToB64Url(buf);
  const resp = await window.MaApi.uploadBlob(Store.state.serverTripId, {
    kind, mime: mime || blob.type || "application/octet-stream", body,
  });
  if (!resp || !resp.id) throw new Error("upload-no-id");
  Store.set(s => ({ ...s, r2Refs: { ...(s.r2Refs || {}), [idbRef]: resp.id } }));
  return resp.id;
};

A.syncMediaQueue = async function() {
  if (_mediaUploading) return { skipped: "busy" };
  if (!syncEnabled() || !Store.state.currentUserId || !Store.state.serverTripId) {
    return { skipped: "sync-disabled-or-not-logged-in" };
  }
  if (!window.MaApi || !window.MaMedia) return { skipped: "deps-missing" };
  _mediaUploading = true;
  const pending = A._collectPendingMediaRefs();
  let uploaded = 0, failed = 0;
  try {
    for (const { ref, kind } of pending) {
      try {
        await A.uploadMediaToR2(ref, kind);
        uploaded++;
      } catch (e) {
        failed++;
        _mediaFailed.set(ref, Date.now() + MEDIA_FAILED_TTL_MS);
        // 401 / 403 → bail whole batch; transport-level, retry later
        if (e && (e.status === 401 || e.status === 403)) {
          console.warn("[mediaSync] auth failure — bailing", e);
          break;
        }
        console.warn("[mediaSync] upload failed", ref, e && (e.message || e));
      }
      // Yield between uploads so UI stays responsive
      await new Promise(r => setTimeout(r, 0));
    }
  } finally {
    _mediaUploading = false;
  }
  return { uploaded, failed, remaining: A._collectPendingMediaRefs().length };
};

// Schedule a background pass — debounced. Called after publishMemory /
// addDocument / similar so newly added blobs get pushed without manual action.
let _mediaSyncTimer = null;
A.scheduleMediaSync = function(delayMs = 2000) {
  if (_mediaSyncTimer) clearTimeout(_mediaSyncTimer);
  _mediaSyncTimer = setTimeout(() => {
    _mediaSyncTimer = null;
    A.syncMediaQueue().catch(e => console.warn("[mediaSync] queue error", e));
  }, delayMs);
};

Object.assign(window, { Store, useStore, A, getTripDayInfo, getCountdown, getStayForDay, getDestinations, destName, destCoords, computeTripStats, COLORS });
