/* Lodestay — the arithmetic. Nothing here holds data and nothing renders; the
   screens call these functions so that two surfaces claiming to describe the
   same money, the same nights or the same set of places cannot disagree.

   The chains that matter:
     · quoteFor is the ONLY place a price is assembled. The card's "from" line,
       the place's breakdown, the checkout preview, the payments table and the
       refund a cancel dialog quotes are all this one function, called with
       different arguments.
     · searchResults is the only place the result set is decided. The count in
       the scope line, the pins on the map and the cards in the list are the
       same array.
     · A rating is the mean of the reviews a place carries, and null below
       STAY_POLICY.ratingMinReviews — so no screen prints an average of two
       opinions.

   Money is euros. lib/ui-runtime.js ships a dollar-locked formatMoney, which
   this product cannot use; formatPrice below is the one spelling for every
   figure in the kit, and the README records why the duplicate exists. */

/* ── Lookups ───────────────────────────────────────────────────────────── */
const listingById = (listings, id) => listings.find((l) => l.id === id) || null;
const hostById = (hosts, id) => hosts.find((h) => h.id === id) || null;
const bookingById = (bookings, id) => bookings.find((b) => b.id === id) || null;
const threadById = (threads, id) => threads.find((t) => t.id === id) || null;
const methodById = (methods, id) => methods.find((m) => m.id === id) || null;
const collectionListings = (collection, listings) => collection.listingIds.map((id) => listingById(listings, id)).filter(Boolean);

const kindOf = (listing) => PLACE_KINDS.find((k) => k.value === listing.kind) || null;
const iconOfKind = (listing) => (kindOf(listing) ? kindOf(listing).icon : "house");
const hoodOf = (listing) => NEIGHBOURHOODS.find((n) => n.value === listing.neighbourhood) || null;
const amenityOf = (value) => AMENITIES.find((a) => a.value === value) || null;
const policyOf = (value) => CANCEL_POLICIES.find((p) => p.value === value) || null;

/* ── Reviews ───────────────────────────────────────────────────────────── */
const reviewsOf = (listing) => listing.reviews;
/* Null, not zero, below the line: the row prints "New place" instead. */
function ratingOf(listing) {
  const list = reviewsOf(listing);
  if (list.length < STAY_POLICY.ratingMinReviews) return null;
  return list.reduce((sum, r) => sum + r.rating, 0) / list.length;
}
const formatRating = (rating) => (rating === null ? "New place" : rating.toFixed(2).replace(/0$/, ""));
/* The share of reviews at five stars — a decomposition of the same array the
   average comes from, never a second set. */
const fiveStarShare = (listing) => {
  const list = reviewsOf(listing);
  return list.length ? list.filter((r) => r.rating === 5).length / list.length : null;
};

/* ── Money ─────────────────────────────────────────────────────────────── */
const priceFormatter = new Intl.NumberFormat("en-GB", { style: "currency", currency: "EUR", minimumFractionDigits: 0, maximumFractionDigits: 0 });
const priceFormatterExact = new Intl.NumberFormat("en-GB", { style: "currency", currency: "EUR", minimumFractionDigits: 2, maximumFractionDigits: 2 });
/* One spelling: whole euros, and cents only when there are cents. */
const formatPrice = (cents) => (cents % 100 === 0 ? priceFormatter.format(cents / 100) : priceFormatterExact.format(cents / 100));

const nightsBetween = (checkIn, checkOut) => Math.round((checkOut - checkIn) / 864e5);

/* THE price. Every figure in the product that has a currency symbol on it
   comes from here, and every line of it names what it is. */
function quoteFor(spec) {
  const nights = nightsBetween(spec.checkIn, spec.checkOut);
  const guests = spec.guests;
  const subtotal = spec.nightlyCents * nights;
  const cleaning = spec.cleaningCents;
  const service = Math.round(subtotal * STAY_POLICY.serviceRate);
  const taxNights = Math.min(nights, STAY_POLICY.cityTaxNightCap);
  const cityTax = STAY_POLICY.cityTaxPerGuestNightCents * guests * taxNights;
  return {
    nights: nights, guests: guests, taxNights: taxNights,
    nightlyCents: spec.nightlyCents,
    subtotal: subtotal, cleaning: cleaning, service: service, cityTax: cityTax,
    total: subtotal + cleaning + service + cityTax,
  };
}
const quoteForListing = (listing, scope) => quoteFor({ nightlyCents: listing.nightlyCents, cleaningCents: listing.cleaningCents, checkIn: scope.checkIn, checkOut: scope.checkOut, guests: scope.guests });
const quoteForBooking = (booking) => quoteFor({ nightlyCents: booking.nightlyCents, cleaningCents: booking.cleaningCents, checkIn: booking.checkIn, checkOut: booking.checkOut, guests: booking.guests });
/* What a night actually costs once the fees are in it — the only honest
   nightly figure, and the one the cards lead with alongside the asking rate. */
const allInPerNight = (quote) => Math.round(quote.total / quote.nights);

/* The lines a breakdown prints. One array, so the place, the checkout and the
   trip cannot itemise differently. */
function quoteLines(quote) {
  return [
    { id: "nights", label: formatPrice(quote.nightlyCents) + " × " + quote.nights + " " + (quote.nights === 1 ? "night" : "nights"), amount: quote.subtotal },
    { id: "cleaning", label: "Cleaning", amount: quote.cleaning },
    { id: "service", label: "Lodestay fee", amount: quote.service, note: Math.round(STAY_POLICY.serviceRate * 100) + "% of the nights" },
    { id: "city_tax", label: "Lisbon city tax", amount: quote.cityTax, note: formatPrice(STAY_POLICY.cityTaxPerGuestNightCents) + " × " + quote.guests + " " + (quote.guests === 1 ? "guest" : "guests") + " × " + quote.taxNights + " " + (quote.taxNights === 1 ? "night" : "nights") },
  ];
}

/* ── Payments on a booking ─────────────────────────────────────────────── */
/* How a total is split into movements of money. One function: the checkout
   preview prints it before the booking exists, and fixtures.js builds the
   payment rows of every booking in the kit from the same call. */
function paymentPlan(spec) {
  const split = spec.checkIn - spec.bookedAt > STAY_POLICY.splitPaymentFromDays * 864e5;
  if (!split) return [{ kind: "full", amountCents: spec.total, dueAt: spec.bookedAt }];
  const deposit = Math.round(spec.total / 2);
  return [
    { kind: "deposit", amountCents: deposit, dueAt: spec.bookedAt },
    { kind: "balance", amountCents: spec.total - deposit, dueAt: spec.checkIn - STAY_POLICY.balanceDueDaysBefore * 864e5 },
  ];
}

const paymentsOf = (booking) => booking.payments;
const paidPayments = (booking) => paymentsOf(booking).filter((p) => p.status === "paid");
const paidCents = (booking) => paidPayments(booking).reduce((sum, p) => sum + p.amountCents, 0);
const refundedCents = (booking) => paymentsOf(booking).filter((p) => p.status === "refunded").reduce((sum, p) => sum + p.amountCents, 0);
const owedPayments = (booking) => paymentsOf(booking).filter((p) => p.status === "scheduled" || p.status === "failed");
const owedCents = (booking) => owedPayments(booking).reduce((sum, p) => sum + p.amountCents, 0);
const failedPayments = (booking) => paymentsOf(booking).filter((p) => p.status === "failed");

/* What comes back if the guest cancels now: the policy's free window against
   the clock, and the share it returns after that. The confirm dialog and the
   trip screen quote this one function. */
function refundFor(booking, now) {
  const policy = policyOf(booking.cancelPolicy);
  const paid = paidCents(booking);
  const freeUntil = booking.checkIn - policy.freeUntilDays * 864e5;
  if (now <= freeUntil) return { refundCents: paid, share: 1, freeUntil: freeUntil, policy: policy, isFree: true };
  return { refundCents: Math.round(paid * policy.refundShare), share: policy.refundShare, freeUntil: freeUntil, policy: policy, isFree: false };
}

/* ── The search ────────────────────────────────────────────────────────── */
/* Places that can be booked at all for the dates in the scope. The count in
   the scope line, the pins on the map and the cards in the list are this
   array or a filter of it. */
const bookableFor = (listings) => listings.filter((l) => LISTING_FLAGS.isBookable(l) || LISTING_FLAGS.isHeld(l));

function searchResults(listings, scope, filters) {
  const text = (filters.text || "").trim().toLowerCase();
  const rows = bookableFor(listings).filter((l) => {
    if (filters.neighbourhood !== "all" && l.neighbourhood !== filters.neighbourhood) return false;
    if (filters.kind !== "all" && l.kind !== filters.kind) return false;
    if (!text) return true;
    return (l.name + " " + l.blurb + " " + labelOf(NEIGHBOURHOODS, l.neighbourhood)).toLowerCase().indexOf(text) > -1;
  });
  const sorted = rows.slice();
  if (filters.sort === "price_low") sorted.sort((a, b) => a.nightlyCents - b.nightlyCents);
  else if (filters.sort === "price_high") sorted.sort((a, b) => b.nightlyCents - a.nightlyCents);
  else if (filters.sort === "rating") sorted.sort((a, b) => (ratingOf(b) || 0) - (ratingOf(a) || 0));
  else {
    /* Best match for the dates: places that fit the party and take the length
       of stay first, then the better rated. */
    const fits = (l) => (LISTING_FLAGS.fitsGuests(l, scope.guests) && LISTING_FLAGS.meetsMinimumNights(l, nightsBetween(scope.checkIn, scope.checkOut)) ? 1 : 0);
    sorted.sort((a, b) => fits(b) - fits(a) || (ratingOf(b) || 0) - (ratingOf(a) || 0));
  }
  return sorted;
}

/* Figures beside the list, computed from the exact array rendered under them. */
function resultStats(rows, scope) {
  if (!rows.length) return null;
  const nightly = rows.map((l) => l.nightlyCents).sort((a, b) => a - b);
  const mid = Math.floor(nightly.length / 2);
  const median = nightly.length % 2 ? nightly[mid] : Math.round((nightly[mid - 1] + nightly[mid]) / 2);
  const totals = rows.map((l) => quoteForListing(l, scope).total);
  return { count: rows.length, medianNightly: median, cheapestTotal: Math.min.apply(null, totals) };
}
const heldRows = (rows) => rows.filter(LISTING_FLAGS.isHeld);

/* ── Trips ─────────────────────────────────────────────────────────────── */
/* Four sections, and every booking is in exactly one of them: the predicates
   partition the array. */
const tripsNeedingYou = (bookings, now) => bookings.filter((b) => BOOKING_FLAGS.needsYou(b, now));
const tripsWaitingOnHost = (bookings) => bookings.filter(BOOKING_FLAGS.isWaitingOnHost);
const tripsUpcoming = (bookings, now) => bookings.filter((b) => (BOOKING_FLAGS.isUpcoming(b, now) || BOOKING_FLAGS.isStaying(b, now)) && !BOOKING_FLAGS.needsYou(b, now));
const tripsPast = (bookings, now) => bookings.filter((b) => BOOKING_FLAGS.isPast(b, now) && !BOOKING_FLAGS.needsYou(b, now));
const nextTrip = (bookings, now) => tripsUpcoming(bookings, now).concat(tripsNeedingYou(bookings, now).filter((b) => b.checkIn > now)).sort((a, b) => a.checkIn - b.checkIn)[0] || null;

/* Nights the guest has actually slept in this city, from the bookings that
   happened — never a stored total. */
const nightsStayed = (bookings) => bookings.filter((b) => b.status === "completed").reduce((sum, b) => sum + nightsBetween(b.checkIn, b.checkOut), 0);
const bookingsWithHost = (bookings, hostId) => bookings.filter((b) => b.hostId === hostId);

/* ── Hosts ─────────────────────────────────────────────────────────────────
   A host page prints nothing of its own. Their places are a filter of the
   listings array; their rating is the mean of the reviews those places carry,
   under the same threshold one place obeys; their reviews are that same array
   sorted. */
const hostListings = (listings, hostId) => listings.filter((l) => l.hostId === hostId);
const hostReviewsWithPlace = (listings, hostId) => hostListings(listings, hostId).reduce((all, l) => all.concat(l.reviews.map((r) => ({ review: r, listing: l }))), []);
const hostReviews = (listings, hostId) => hostReviewsWithPlace(listings, hostId).map((entry) => entry.review);
const hostReviewsRecent = (listings, hostId, count) => hostReviewsWithPlace(listings, hostId).sort((a, b) => b.review.at - a.review.at).slice(0, count);
function hostRating(listings, hostId) {
  const list = hostReviews(listings, hostId);
  if (list.length < STAY_POLICY.ratingMinReviews) return null;
  return list.reduce((sum, r) => sum + r.rating, 0) / list.length;
}
/* Stays that actually happened, as against every booking ever made with them. */
const staysWithHost = (bookings, hostId) => bookingsWithHost(bookings, hostId).filter((b) => b.status === "completed");
const threadWithHost = (threads, hostId) => threads.find((t) => t.hostId === hostId) || null;

/* ── Countdowns ────────────────────────────────────────────────────────────
   Two spellings, one job each, both from here: what a link has left is
   measured in minutes, what a person is sitting through is measured on a
   clock. Direction is in the words, never in colour alone. */
const formatMinutesLeft = (ms) => { const m = Math.max(0, Math.ceil(ms / 60000)); return m + (m === 1 ? " minute" : " minutes"); };
const formatClockLeft = (ms) => { const s = Math.max(0, Math.ceil(ms / 1000)); return Math.floor(s / 60) + ":" + String(s % 60).padStart(2, "0"); };

/* ── Saved ─────────────────────────────────────────────────────────────── */
const savedListingIds = (collections) => collections.reduce((ids, c) => ids.concat(c.listingIds.filter((id) => ids.indexOf(id) === -1)), []);
const savedUnavailable = (collections, listings) => savedListingIds(collections).map((id) => listingById(listings, id)).filter((l) => l && !LISTING_FLAGS.isBookable(l) && !LISTING_FLAGS.isHeld(l));

/* ── Inbox ─────────────────────────────────────────────────────────────── */
const threadsByRecency = (threads) => threads.slice().sort((a, b) => THREAD_FLAGS.lastMessage(b).at - THREAD_FLAGS.lastMessage(a).at);
const unreadThreads = (threads) => threads.filter(THREAD_FLAGS.isUnread);
const threadForBooking = (threads, bookingId) => threads.find((t) => t.bookingId === bookingId) || null;

/* ── Stay dates ────────────────────────────────────────────────────────────
   A stay date is a calendar date, not an event time: it is always absolute and
   always in this one spelling. §8.3's distance rules govern the timestamps in
   this product — messages, payments, holds — through lib/ui-runtime.js. */
const DAY_MONTH = { day: "numeric", month: "short" };
const formatDay = (ts) => new Date(ts).toLocaleDateString("en-GB", DAY_MONTH);
const formatDayFull = (ts) => new Date(ts).toLocaleDateString("en-GB", { weekday: "short", day: "numeric", month: "short" });
function formatStayRange(checkIn, checkOut) {
  const a = new Date(checkIn), b = new Date(checkOut);
  const sameMonth = a.getMonth() === b.getMonth() && a.getFullYear() === b.getFullYear();
  return sameMonth
    ? a.getDate() + "–" + b.getDate() + " " + b.toLocaleDateString("en-GB", { month: "short" })
    : formatDay(checkIn) + " – " + formatDay(checkOut);
}
const formatNights = (n) => n + (n === 1 ? " night" : " nights");
const formatGuests = (n) => n + (n === 1 ? " guest" : " guests");
const formatStayLine = (checkIn, checkOut, guests) => formatStayRange(checkIn, checkOut) + " · " + formatNights(nightsBetween(checkIn, checkOut)) + " · " + formatGuests(guests);
const checkInClock = (ts) => new Date(ts).toLocaleTimeString("en-GB", { hour: "2-digit", minute: "2-digit" });

/* ── Nav badges ────────────────────────────────────────────────────────────
   Each counts one predicate over one array — the same predicate the rows and
   the section headings call. */
const NAV_BADGES = {
  tripsNeedingYou: { label: "trips need something from you", count: (data) => tripsNeedingYou(data.bookings, data.now).length },
  unreadThreads: { label: "unread conversations", count: (data) => unreadThreads(data.threads).length },
};

Object.assign(window, {
  listingById, hostById, bookingById, threadById, methodById, collectionListings,
  kindOf, iconOfKind, hoodOf, amenityOf, policyOf,
  reviewsOf, ratingOf, formatRating, fiveStarShare,
  formatPrice, nightsBetween, quoteFor, quoteForListing, quoteForBooking, allInPerNight, quoteLines,
  paymentsOf, paidPayments, paidCents, refundedCents, owedPayments, owedCents, failedPayments, refundFor, paymentPlan,
  bookableFor, searchResults, resultStats, heldRows,
  tripsNeedingYou, tripsWaitingOnHost, tripsUpcoming, tripsPast, nextTrip, nightsStayed, bookingsWithHost,
  hostListings, hostReviews, hostReviewsWithPlace, hostReviewsRecent, hostRating, staysWithHost, threadWithHost,
  formatMinutesLeft, formatClockLeft,
  savedListingIds, savedUnavailable,
  threadsByRecency, unreadThreads, threadForBooking,
  formatDay, formatDayFull, formatStayRange, formatNights, formatGuests, formatStayLine, checkInClock,
  NAV_BADGES,
});
