/* Lodestay — the product's own routing and its commit machinery.
   Every action that changes anything goes through this file, so the
   reversibility tiers are decided in one place: harmless controls apply bare on
   their screens, consequential ones apply here and raise an undo toast,
   irreversible ones open a confirm dialog, and anything needing two fields
   opens a staged dialog.

   The chains that matter, and the reason the rules ask for them:
     · Reserving writes the booking AND takes the place off the market: the
       search count drops by one, the saved list says "Booked", and the new trip
       carries the payment schedule the checkout screen printed.
     · Writing a review writes it onto the booking AND onto the place, so the
       place's rating — the mean of its own reviews — moves while you watch, and
       a place with two reviews crosses the line into having a rating at all.
     · Paying a balance or retrying a failed card moves the payment, which moves
       the trips badge, because the badge counts the predicate rather than a
       stored flag.
     · Opening an unread conversation marks the host's messages read, which
       moves the messages badge.
     · Cancelling quotes refundFor in the dialog and then writes exactly that
       refund, so no screen can disagree about what came back. */
function App(props) {
  const options = props || {};
  const [path, setPath] = React.useState(options.startPath || "/");
  const [dark, setDark] = React.useState(options.theme === "dark");
  const [toasts, setToasts] = React.useState([]);
  const [confirm, setConfirm] = React.useState(null);
  const [form, setForm] = React.useState(null);
  const [read, setRead] = React.useState(false);
  const [thread, setThread] = React.useState(null);
  /* Auth carries two pieces of state between its screens: the address the
     reader typed (so the confirmation can name it) and the moment a lockout
     ends (so the countdown is the real wait, not a fresh fifteen minutes). */
  const [authEmail, setAuthEmail] = React.useState(LODESTAY_USER.email);
  const [lockedUntil, setLockedUntil] = React.useState(null);

  const scopeFor = (dates, guests) => {
    const option = SEARCH_DATE_OPTIONS.find((o) => o.value === dates) || SEARCH_DATE_OPTIONS[0];
    return { dates: option.value, checkIn: option.checkIn, checkOut: option.checkOut, guests: guests };
  };
  const [scope, setScope] = React.useState(() => scopeFor(options.dates || SEARCH_SCOPE.dates, options.guests || SEARCH_SCOPE.guests));
  const [filters, setFilters] = React.useState({ text: "", neighbourhood: "all", kind: "all", sort: "recommended" });
  const [user, setUser] = React.useState(LODESTAY_USER);
  const [methods, setMethods] = React.useState(PAYMENT_METHODS);
  const [data, setData] = React.useState(() => ({
    listings: LISTINGS, hosts: HOSTS, bookings: BOOKINGS, threads: THREADS,
    collections: COLLECTIONS, methods: PAYMENT_METHODS, now: LODESTAY_NOW,
  }));
  const now = data.now;
  const palette = useCommandPalette();

  React.useEffect(() => { if (options.startPath) setPath(options.startPath); }, [options.startPath]);
  React.useEffect(() => { setDark(options.theme === "dark"); }, [options.theme]);
  React.useEffect(() => { setScope(scopeFor(options.dates || SEARCH_SCOPE.dates, options.guests || SEARCH_SCOPE.guests)); }, [options.dates, options.guests]);
  React.useEffect(() => {
    document.documentElement.classList.toggle("dark", dark);
    document.body.classList.toggle("dark", dark);
  }, [dark]);
  /* The methods list is edited on its own screen and read by every payment row,
     so the one copy inside `data` follows it. */
  React.useEffect(() => { setData((d) => Object.assign({}, d, { methods: methods })); }, [methods]);

  const dismiss = (id) => setToasts((list) => list.filter((t) => t.id !== id));
  const pushToast = (toast) => {
    const id = "t" + Date.now() + Math.random().toString(36).slice(2, 6);
    setToasts((list) => list.concat([Object.assign({ id: id }, toast)]));
    window.setTimeout(() => dismiss(id), 8000);
  };
  /* Undo restores the whole fixture as it was before the commit — one
     mechanism, so no action has to remember which records it touched. */
  const undoTo = (snapshot) => () => setData(snapshot);

  const patchBooking = (id, fields) => setData((d) => Object.assign({}, d, { bookings: d.bookings.map((b) => (b.id === id ? Object.assign({}, b, fields) : b)) }));
  const patchListing = (id, fields) => setData((d) => Object.assign({}, d, { listings: d.listings.map((l) => (l.id === id ? Object.assign({}, l, fields) : l)) }));
  const patchPayment = (bookingId, paymentId, fields) => setData((d) => Object.assign({}, d, {
    bookings: d.bookings.map((b) => (b.id === bookingId
      ? Object.assign({}, b, { payments: b.payments.map((p) => (p.id === paymentId ? Object.assign({}, p, fields) : p)) })
      : b)),
  }));
  const appendMessage = (thread_, body) => setData((d) => Object.assign({}, d, {
    threads: d.threads.map((t) => (t.id === thread_.id
      ? Object.assign({}, t, { messages: t.messages.concat([{ id: "m" + (t.messages.length + 1), from: "guest", at: now, body: body, read: true }]) })
      : t)),
  }));

  const saved = savedListingIds(data.collections);

  /* ── Saving a place: instant and harmless, so it applies bare. ─────────── */
  const onSave = (listing) => {
    const inList = saved.indexOf(listing.id) > -1;
    setData((d) => Object.assign({}, d, {
      collections: d.collections.map((c, i) => {
        if (inList) return Object.assign({}, c, { listingIds: c.listingIds.filter((id) => id !== listing.id) });
        return i === 0 ? Object.assign({}, c, { listingIds: c.listingIds.concat([listing.id]) }) : c;
      }),
    }));
  };

  const onFindSimilar = (listing) => {
    setFilters({ text: "", neighbourhood: listing.neighbourhood, kind: "all", sort: "recommended" });
    setPath("/");
  };

  /* ── A place: the only transition is the one that starts a booking. ───── */
  const onRunListing = (listing, transition) => {
    if (transition.id === "reserve" || transition.id === "request_book") setPath("/stays/" + listing.id + "/book");
  };

  /* ── Checkout, staged: one commit, and everything derived from it moves. ─ */
  const onSubmitBooking = (listing, values) => {
    const snapshot = data;
    const instant = LISTING_FLAGS.isInstant(listing);
    const quote = quoteFor({ nightlyCents: listing.nightlyCents, cleaningCents: listing.cleaningCents, checkIn: scope.checkIn, checkOut: scope.checkOut, guests: values.guests });
    const plan = paymentPlan({ total: quote.total, checkIn: scope.checkIn, bookedAt: now });
    const id = "b_new_" + listing.id;
    const booking = {
      id: id,
      reference: "LS-" + listing.id.slice(2, 5).toUpperCase() + "-" + new Date(scope.checkIn).getFullYear(),
      listingId: listing.id, hostId: listing.hostId,
      status: instant ? "confirmed" : "requested",
      checkIn: scope.checkIn, checkOut: scope.checkOut, guests: values.guests, purpose: values.purpose,
      bookedAt: now, nightlyCents: listing.nightlyCents, cleaningCents: listing.cleaningCents,
      cancelPolicy: listing.cancelPolicy, arrivalPlan: null, hostNote: null,
      closedAt: null, closedNote: null, cancelReason: null, review: null,
      respondBy: instant ? null : now + 864e5,
      /* A request charges nothing until the host accepts. */
      payments: instant
        ? plan.map((row, i) => ({ id: id + "_p" + (i + 1), kind: row.kind, amountCents: row.amountCents, status: row.dueAt <= now ? "paid" : "scheduled", method: values.method, dueAt: row.dueAt, paidAt: row.dueAt <= now ? now : null }))
        : [],
    };
    const host = hostById(data.hosts, listing.hostId);
    setData((d) => {
      const existing = d.threads.find((t) => t.listingId === listing.id);
      const message = { id: "m" + ((existing ? existing.messages.length : 0) + 1), from: "guest", at: now, body: values.message || (instant ? "Booked for " + formatStayRange(scope.checkIn, scope.checkOut) + ". Looking forward to it." : "Asking about " + formatStayRange(scope.checkIn, scope.checkOut) + "."), read: true };
      return Object.assign({}, d, {
        bookings: d.bookings.concat([booking]),
        /* Reserving takes the dates off the market: the search count, the pins
           and the saved list all answer differently from here on. */
        listings: instant ? d.listings.map((l) => (l.id === listing.id ? Object.assign({}, l, { availability: "booked" }) : l)) : d.listings,
        threads: existing
          ? d.threads.map((t) => (t.id === existing.id ? Object.assign({}, t, { bookingId: id, messages: t.messages.concat([message]) }) : t))
          : d.threads.concat([{ id: "t_new_" + listing.id, hostId: listing.hostId, bookingId: id, listingId: listing.id, messages: [message] }]),
      });
    });
    pushToast({
      variant: "success",
      title: instant ? "Reserved — " + listing.name : "Request sent to " + host.name.split(" ")[0],
      description: instant
        ? formatPrice(plan[0].amountCents) + " taken now" + (plan.length > 1 ? ", " + formatPrice(plan[1].amountCents) + " due " + formatDayFull(plan[1].dueAt) : "") + " · " + formatStayLine(scope.checkIn, scope.checkOut, values.guests)
        : "They have 24 hours to answer. Nothing has been charged — " + formatPrice(quote.total) + " for " + formatNights(quote.nights) + " if they accept.",
      onUndo: undoTo(snapshot),
    });
    setPath("/trips/" + id);
  };

  /* ── A trip. Every branch of bookingTransitions lands here. ───────────── */
  const onRunBooking = (booking, transition) => {
    const snapshot = data;
    const listing = listingById(data.listings, booking.listingId);
    const host = hostById(data.hosts, booking.hostId);

    if (transition.id === "message_host") {
      const existing = threadForBooking(data.threads, booking.id) || data.threads.find((t) => t.listingId === booking.listingId);
      if (existing) setThread(existing.id);
      setPath("/inbox");
      return;
    }
    if (transition.id === "book_again") { setPath("/stays/" + booking.listingId); return; }
    if (transition.id === "find_similar") { onFindSimilar(listing); return; }

    if (transition.id === "retry_payment" || transition.id === "pay_balance") {
      const payment = transition.id === "retry_payment" ? failedPayments(booking)[0] : BOOKING_FLAGS.scheduledBalance(booking);
      const method = methodById(data.methods, payment.method);
      patchPayment(booking.id, payment.id, { status: "paid", paidAt: now, failedReason: undefined });
      pushToast({
        variant: "success",
        title: formatPrice(payment.amountCents) + " paid",
        description: labelOf(PAYMENT_KINDS, payment.kind) + " on " + listing.name + " · " + method.brand + " ···· " + method.last4 + (transition.id === "pay_balance" ? ". It was not due until " + formatDayFull(payment.dueAt) + "." : ". " + host.name.split(" ")[0] + " never had to know."),
        onUndo: undoTo(snapshot),
      });
      return;
    }

    if (transition.id === "share_arrival") {
      const thread_ = threadForBooking(data.threads, booking.id);
      setForm({
        title: "What time will you get there?",
        description: host.name.split(" ")[0] + " asked " + formatWhen(thread_ ? THREAD_FLAGS.lastMessage(thread_).at : booking.bookedAt, now) + ". Check-in at " + listing.name + " is " + formatDayFull(booking.checkIn) + " from " + checkInClock(booking.checkIn) + ".",
        submitLabel: "Send arrival time",
        initial: { when: "", note: "" },
        fields: [
          { id: "when", kind: "select", label: "Arriving", placeholder: "Pick a time", options: ["15:00", "16:00", "17:00", "18:00", "19:00", "20:00", "After 21:00"].map((t) => ({ value: t, label: t })) },
          { id: "note", kind: "text", label: "Anything else", placeholder: "Coming straight from the airport, one bag.", hint: "Goes into the conversation with the arrival time." },
        ],
        validate: (v) => (v.when ? {} : { when: "Pick a time, even roughly." }),
        run: (v) => {
          const plan = "Arriving around " + v.when + (v.note.trim() ? ". " + v.note.trim() : ".");
          patchBooking(booking.id, { arrivalPlan: plan });
          if (thread_) appendMessage(thread_, plan);
          pushToast({ variant: "success", title: "Arrival time sent", description: host.name.split(" ")[0] + " has it: " + plan, onUndo: undoTo(snapshot) });
        },
      });
      return;
    }

    if (transition.id === "write_review") {
      setForm({
        title: "How was " + listing.name + "?",
        description: "You stayed " + formatStayRange(booking.checkIn, booking.checkOut) + ". The window closes " + formatDayFull(booking.checkOut + STAY_POLICY.reviewWindowDays * 864e5) + ", and " + host.name.split(" ")[0] + " sees it once it is written." + (LISTING_FLAGS.isNew(listing) ? " This place has " + listing.reviews.length + " reviews — yours is the one that gives it a rating." : ""),
        submitLabel: "Publish review",
        initial: { rating: "", body: "" },
        fields: [
          { id: "rating", kind: "radio", label: "Out of five", options: [5, 4, 3, 2, 1].map((n) => ({ value: String(n), label: n + (n === 5 ? " — everything as described" : n === 4 ? " — good, with one thing to say" : n === 3 ? " — fine" : n === 2 ? " — problems" : " — do not stay") })) },
          { id: "body", kind: "text", label: "One or two sentences", placeholder: "The lemon tree in the yard was the best part of the week.", hint: "Guests read these more than they read the description." },
        ],
        validate: (v) => {
          const errors = {};
          if (!v.rating) errors.rating = "Pick a rating.";
          if (v.body.trim().length < 12) errors.body = "A sentence is enough, but write one.";
          return errors;
        },
        run: (v) => {
          const rating = parseInt(v.rating, 10);
          const review = { id: "r_new_" + booking.id, name: user.name.split(" ")[0], at: now, rating: rating, body: v.body.trim() };
          patchBooking(booking.id, { review: { rating: rating, at: now, body: v.body.trim() } });
          /* Onto the place as well: its rating is the mean of this array. */
          patchListing(listing.id, { reviews: listing.reviews.concat([review]) });
          const next = listing.reviews.concat([review]);
          const mean = next.reduce((sum, r) => sum + r.rating, 0) / next.length;
          pushToast({
            variant: "success",
            title: "Review published",
            description: next.length < STAY_POLICY.ratingMinReviews
              ? listing.name + " now has " + next.length + " reviews — " + STAY_POLICY.ratingMinReviews + " are needed before a rating shows."
              : listing.name + " is now " + mean.toFixed(2).replace(/0$/, "") + " from " + next.length + " reviews.",
            onUndo: undoTo(snapshot),
          });
        },
      });
      return;
    }

    if (transition.id === "cancel_request") {
      setConfirm({
        title: "Withdraw your request to " + host.name.split(" ")[0] + "?",
        description: "The request for " + listing.name + ", " + formatStayLine(booking.checkIn, booking.checkOut, booking.guests) + ", disappears from their queue. Nothing has been charged and nothing will be. Asking again later means going to the back of the queue.",
        confirmLabel: "Withdraw request",
        destructive: true,
        run: () => {
          patchBooking(booking.id, { status: "cancelled", closedAt: now, cancelReason: "trip_off" });
          pushToast({ title: "Request withdrawn", description: host.name.split(" ")[0] + " will not see it. " + listing.name + " is still open for " + formatStayRange(booking.checkIn, booking.checkOut) + "." });
        },
      });
      return;
    }

    if (transition.id === "cancel_booking") {
      const refund = refundFor(booking, now);
      const paid = paidCents(booking);
      setForm({
        title: "Cancel " + formatNights(nightsBetween(booking.checkIn, booking.checkOut)) + " at " + listing.name + "?",
        description: (refund.isFree
          ? "You are inside the " + refund.policy.label.toLowerCase() + " window, so all " + formatPrice(refund.refundCents) + " comes back to the " + methodById(data.methods, booking.payments[0].method).brand + " within five days."
          : "The " + refund.policy.label.toLowerCase() + " window closed on " + formatDayFull(refund.freeUntil) + ", so " + formatPrice(refund.refundCents) + " of the " + formatPrice(paid) + " paid comes back and the rest stays with " + host.name.split(" ")[0] + ".") +
          (owedCents(booking) ? " The " + formatPrice(owedCents(booking)) + " still scheduled will not be taken." : "") +
          " This cannot be undone, and the dates go back on the market immediately.",
        submitLabel: "Cancel booking",
        destructive: true,
        initial: {},
        fields: [{ id: "reason", kind: "radio", label: "Why, for the host", options: CANCEL_REASONS }],
        validate: (v) => (v.reason ? {} : { reason: "Pick what the host should be told." }),
        run: (v) => {
          setData((d) => Object.assign({}, d, {
            bookings: d.bookings.map((b) => (b.id === booking.id
              ? Object.assign({}, b, {
                status: "cancelled", closedAt: now, cancelReason: v.reason,
                payments: b.payments
                  .filter((p) => p.status !== "scheduled")
                  .concat(refund.refundCents ? [{ id: b.id + "_refund", kind: "refund", amountCents: refund.refundCents, status: "refunded", method: b.payments[0].method, paidAt: now, dueAt: now }] : []),
              })
              : b)),
            /* Back on the market, which the search count will say. */
            listings: d.listings.map((l) => (l.id === listing.id && l.availability === "booked" ? Object.assign({}, l, { availability: "open" }) : l)),
          }));
          pushToast({
            title: "Booking cancelled",
            description: (refund.refundCents ? formatPrice(refund.refundCents) + " on its way back · " : "Nothing to refund · ") + host.name.split(" ")[0] + " was told: " + labelOf(CANCEL_REASONS, v.reason).toLowerCase() + ".",
          });
        },
      });
    }
  };

  /* ── Conversations ─────────────────────────────────────────────────────── */
  const onSelectThread = (id) => {
    setThread(id);
    setData((d) => Object.assign({}, d, {
      threads: d.threads.map((t) => (t.id === id ? Object.assign({}, t, { messages: t.messages.map((m) => Object.assign({}, m, { read: true })) }) : t)),
    }));
  };
  const onSend = (thread_, body) => {
    appendMessage(thread_, body);
    const host = hostById(data.hosts, thread_.hostId);
    pushToast({ title: "Sent to " + host.name.split(" ")[0], description: replyLine(host).replace("Usually answers", "They usually answer") + "." });
  };

  /* ── Account ───────────────────────────────────────────────────────────── */
  const onSaveProfile = (draft) => {
    const before = user;
    setUser(Object.assign({}, user, { name: draft.name.trim(), email: draft.email.trim(), phone: draft.phone.trim(), homeCity: draft.homeCity.trim() }));
    pushToast({ variant: "success", title: "Profile saved", description: draft.name.trim() + " · " + draft.email.trim(), onUndo: () => setUser(before) });
  };
  const onMakeDefault = (method) => {
    const before = methods;
    setMethods(methods.map((m) => Object.assign({}, m, { isDefault: m.id === method.id })));
    pushToast({ title: method.brand + " ···· " + method.last4 + " is now the default", description: "New bookings use it. Payments already scheduled keep the card they were booked with.", onUndo: () => setMethods(before) });
  };
  const onRemoveMethod = (method) => {
    const fallback = methods.find((m) => m.isDefault && m.id !== method.id) || methods.find((m) => m.id !== method.id);
    const moving = data.bookings.filter((b) => owedPayments(b).some((p) => p.method === method.id));
    setConfirm({
      title: "Remove the " + method.brand + " ending " + method.last4 + "?",
      description: (moving.length
        ? moving.length + (moving.length === 1 ? " stay is" : " stays are") + " scheduled to charge it — " + moving.map((b) => listingById(data.listings, b.listingId).name + ", " + formatPrice(owedCents(b))).join(" and ") + ". Those payments move to the " + fallback.brand + " ending " + fallback.last4 + ". "
        : "Nothing is scheduled to charge it. ") + "Payments already taken keep their record. The card cannot be added back from this screen.",
      confirmLabel: "Remove card",
      destructive: true,
      run: () => {
        setMethods(methods.filter((m) => m.id !== method.id));
        setData((d) => Object.assign({}, d, {
          bookings: d.bookings.map((b) => Object.assign({}, b, {
            payments: b.payments.map((p) => (p.method === method.id && (p.status === "scheduled" || p.status === "failed") ? Object.assign({}, p, { method: fallback.id }) : p)),
          })),
        }));
        pushToast({
          title: method.brand + " removed",
          description: moving.length ? formatPrice(moving.reduce((sum, b) => sum + owedCents(b), 0)) + " moved to the " + fallback.brand + " ending " + fallback.last4 + "." : "Nothing else changed.",
        });
      },
    });
  };

  /* ── Routing ───────────────────────────────────────────────────────────── */
  /* Auth has no navigation owner, so it is returned before the shell. */
  /* Harness chrome, not product chrome: the floating page menu is how a reader
     reaches every registered screen, including the auth ones the shell cannot
     render. It sits outside the shell so it is on those screens too. */
  const preview = (
    <TemplateMenu product="Lodestay" path={path} onNavigate={setPath} groups={LODESTAY_PAGES(data)}
      dark={dark} onToggleTheme={() => setDark(!dark)} />
  );

  if (isAuthPath(path)) {
    /* The token is in the URL, and /reset-password/done is a literal in the
       same namespace, so it is matched out before the token is read. */
    const tokenMatch = path.match(/^\/reset-password\/([^/]+)$/);
    const token = tokenMatch && tokenMatch[1] !== "done" ? tokenMatch[1] : null;
    const link = token ? RESET_LINKS.find((l) => l.token === token) || null : null;
    const authScreen = () => {
      if (path === "/signup") return <SignUpScreen onNavigate={setPath} onSubmit={(email) => { setAuthEmail(email); setPath("/verify-email"); }} />;
      if (path === "/verify-email") return <VerifyEmailScreen email={authEmail} onNavigate={setPath} />;
      if (path === "/forgot-password") return <ForgotPasswordScreen email={authEmail} onNavigate={setPath} onSubmit={(email) => { setAuthEmail(email); setPath("/forgot-password/sent"); }} />;
      if (path === "/forgot-password/sent") return <ResetSentScreen email={authEmail} onNavigate={setPath} />;
      if (path === "/reset-password/done") return <ResetDoneScreen onNavigate={setPath} />;
      if (token) return <SetNewPasswordScreen link={link} now={now} onNavigate={setPath} onDone={() => setPath("/reset-password/done")} />;
      if (path === "/account-locked") return <AccountLockedScreen until={lockedUntil} onNavigate={setPath} />;
      if (path === "/signout") return <SignedOutScreen user={user} onSignIn={() => setPath("/signin")} />;
      return (
        <SignInScreen
          user={user} onNavigate={setPath} onSignIn={() => setPath("/")}
          onLock={() => { setLockedUntil(Date.now() + AUTH_POLICY.lockMinutes * 60000); setPath("/account-locked"); }}
        />
      );
    };
    return (
      <>
        {authScreen()}
        {preview}
      </>
    );
  }

  const stayMatch = path.match(/^\/stays\/([^/]+)$/);
  const bookMatch = path.match(/^\/stays\/([^/]+)\/book$/);
  const tripMatch = path.match(/^\/trips\/([^/]+)$/);
  const receiptMatch = path.match(/^\/trips\/([^/]+)\/receipt$/);
  const hostMatch = path.match(/^\/hosts\/([^/]+)$/);
  const host = hostMatch ? hostById(data.hosts, hostMatch[1]) : null;
  const receipt = receiptMatch ? bookingById(data.bookings, receiptMatch[1]) : null;
  const listing = stayMatch || bookMatch ? listingById(data.listings, (stayMatch || bookMatch)[1]) : null;
  const booking = tripMatch ? bookingById(data.bookings, tripMatch[1]) : null;

  const screen = () => {
    if (receipt) return <ReceiptScreen booking={receipt} data={data} user={user} onNavigate={setPath} pushToast={pushToast} />;
    if (path === "/") {
      return <SearchScreen
        data={data} scope={scope} onScopeChange={setScope} filters={filters} onFilterChange={setFilters}
        saved={saved} onSave={onSave} onNavigate={setPath} onRun={onRunListing}
      />;
    }
    if (path === "/trips") return <TripsScreen data={data} onNavigate={setPath} onRun={onRunBooking} />;
    if (path === "/saved") return <SavedScreen data={data} scope={scope} saved={saved} onSave={onSave} onNavigate={setPath} onRun={onRunListing} />;
    if (path === "/inbox") return <InboxScreen data={data} selectedId={thread} onSelect={onSelectThread} onSend={onSend} onNavigate={setPath} />;
    if (path === "/account/profile") return <ProfileScreen user={user} data={data} onNavigate={setPath} onSave={onSaveProfile} />;
    if (path === "/account/payments") return <PaymentsScreen data={data} methods={methods} onNavigate={setPath} onMakeDefault={onMakeDefault} onRemove={onRemoveMethod} />;
    if (bookMatch && listing) return <CheckoutScreen listing={listing} data={data} scope={scope} user={user} methods={methods} onNavigate={setPath} onSubmit={onSubmitBooking} />;
    if (host) {
      return <HostScreen
        host={host} data={data} scope={scope} saved={saved} onSave={onSave} onNavigate={setPath} onRun={onRunListing}
        onMessage={(t) => { onSelectThread(t.id); setPath("/inbox"); }}
      />;
    }
    if (listing) return <StayScreen listing={listing} data={data} scope={scope} saved={saved.indexOf(listing.id) > -1} onSave={onSave} onNavigate={setPath} onRun={onRunListing} onFindSimilar={onFindSimilar} />;
    if (booking) return <TripScreen booking={booking} data={data} onNavigate={setPath} onRun={onRunBooking} />;
    return <RouteNotBuilt path={path} onNavigate={setPath} />;
  };

  const recordGroups = [
    { label: "Trips", items: data.bookings.map((b) => ({ value: b.id, label: listingById(data.listings, b.listingId).name + " · " + formatStayRange(b.checkIn, b.checkOut) + " · " + statusValue(BOOKING_STATUS, b.status).label.toLowerCase(), route: "/trips/" + b.id })) },
    { label: "Places", items: data.listings.map((l) => ({ value: l.id, label: l.name + " · " + labelOf(NEIGHBOURHOODS, l.neighbourhood) + " · " + formatPrice(l.nightlyCents) + " a night", route: "/stays/" + l.id })) },
    { label: "Hosts", items: data.hosts.map((h) => ({ value: h.id, label: h.name + " · " + hostListings(data.listings, h.id).length + " places · lives in " + h.lives, route: "/hosts/" + h.id })) },
  ];

  return (
    <LodestayShell
      path={path}
      onNavigate={setPath}
      dark={dark}
      onToggleTheme={() => setDark(!dark)}
      data={data}
      user={user}
      notifications={NOTIFICATIONS}
      unread={read ? 0 : NOTIFICATIONS.length}
      onRead={() => setRead(true)}
      palette={palette}
      toasts={toasts}
      onDismissToast={dismiss}
    >
      {screen()}
      <CommandPalette
        open={palette.open}
        onClose={() => palette.setOpen(false)}
        routes={LODESTAY_ROUTES}
        recordGroups={recordGroups}
        onNavigate={setPath}
        placeholder="Search places, trips, hosts…"
      />
      <ConfirmDialog request={confirm} onCancel={() => setConfirm(null)} onConfirm={() => { confirm.run(); setConfirm(null); }} />
      <FormDialog request={form} onCancel={() => setForm(null)} onSubmit={(values) => { form.run(values); setForm(null); }} />
      {preview}
    </LodestayShell>
  );
}
/* boot.js publishes a stub on window.LodestayApp before anything is fetched, so
   <x-import> always resolves; the real component lands here and the stub swaps
   it in. */
window.__lodestayReal = App;
if (!window.LodestayApp) window.LodestayApp = App;
