import { v } from "convex/values";
import { mutation, query, type MutationCtx, type QueryCtx } from "./_generated/server";
import type { Doc, Id } from "./_generated/dataModel";
import { assertPermission, can, actorLite, AppError, requireActor, type Actor } from "./lib/access";
import { audit } from "./lib/audit";
import { notify } from "./lib/notify";
import { nextCode } from "./lib/seq";
import { getSettings } from "./lib/settings";
import { findConflicts, type Conflict } from "./lib/availability";
import { inventoryEvent, attributeRecovery, syncApartmentStatus, effectiveCheckOut } from "./lib/inventory";
import { evaluateCommission, cancelCommissionsFor } from "./lib/commission";
import { computePricing } from "../src/lib/pricing";
import { parseKey, nightsBetweenKeys, todayKey, eachNightKey } from "./lib/days";
import { fmtMoney } from "../src/lib/format";
import { RESERVATION_SOURCES, PAYMENT_METHODS, RISK_LEVEL_META, type RiskLevel } from "../src/lib/domain";
import { screenGuest, overlappingStays } from "./lib/screening";
import { liveHoldsFor } from "./frontDesk";

const dayArg = v.string();
const CLOSED = ["CANCELLED", "CHECKED_OUT", "NO_SHOW"];

function assertDay(k: string, label = "date") {
  if (!/^\d{4}-\d{2}-\d{2}$/.test(k)) throw new AppError(`Invalid ${label}`, "VALIDATION");
}
function conflictError(conflicts: Conflict[]) {
  return new AppError(`Not available: ${conflicts.map((c) => c.label).join(", ")}`, "CONFLICT", { conflicts });
}
async function history(ctx: MutationCtx, reservationId: Id<"reservations">, h: { type: string; previousValue?: unknown; newValue?: unknown; reason?: string | null; fromApartmentId?: Id<"apartments">; toApartmentId?: Id<"apartments">; priceDifference?: number; performedById?: Id<"users"> }) {
  await ctx.db.insert("reservationHistory", { reservationId, type: h.type, previousValue: h.previousValue === undefined ? undefined : JSON.stringify(h.previousValue), newValue: h.newValue === undefined ? undefined : JSON.stringify(h.newValue), reason: h.reason ?? undefined, fromApartmentId: h.fromApartmentId, toApartmentId: h.toApartmentId, priceDifference: h.priceDifference, performedById: h.performedById, at: Date.now() });
}
export async function loadReservation(ctx: MutationCtx, id: Id<"reservations">) {
  const r = await ctx.db.get(id);
  if (!r) throw new AppError("Reservation not found", "NOT_FOUND");
  const [apartment, customer] = await Promise.all([ctx.db.get(r.apartmentId), ctx.db.get(r.customerId)]);
  if (!apartment || !customer) throw new AppError("Reservation is missing its apartment or customer", "NOT_FOUND");
  return { r, apartment, customer };
}
/** Workers without reservations.view_all only see their own reservations. */
export function canSeeReservation(actor: Actor, r: Doc<"reservations">) {
  return can(actor, "reservations.view_all") || r.createdById === actor.id || r.assignedToId === actor.id;
}

// ── Create ───────────────────────────────────────────────────
export const create = mutation({
  args: {
    customerId: v.id("customers"),
    apartmentId: v.id("apartments"),
    checkIn: dayArg,
    checkOut: dayArg,
    adults: v.number(),
    children: v.optional(v.number()),
    source: v.string(),
    status: v.optional(v.string()),
    nightlyPrice: v.optional(v.number()),
    discount: v.optional(v.number()),
    deposit: v.optional(v.number()),
    amountPaid: v.optional(v.number()),
    paymentMethod: v.optional(v.union(v.string(), v.null())),
    assignedToId: v.optional(v.union(v.id("users"), v.null())),
    internalNotes: v.optional(v.union(v.string(), v.null())),
    customerRequests: v.optional(v.union(v.string(), v.null())),
    externalRef: v.optional(v.union(v.string(), v.null())),
    riskOverride: v.optional(v.boolean()),
    /** the guest knowingly takes a second apartment on the same nights */
    allowSecondStay: v.optional(v.boolean()),
  },
  returns: v.union(v.object({ id: v.id("reservations"), code: v.string() }), v.object({ blocked: v.literal(true), message: v.string() })),
  handler: async (ctx, data) => {
    const user = await assertPermission(ctx, "reservations.create");
    const actor = actorLite(user);
    assertDay(data.checkIn, "check-in");
    assertDay(data.checkOut, "check-out");
    if (data.checkOut <= data.checkIn) throw new AppError("Check-out must be after check-in", "VALIDATION", { fields: { checkOut: "Check-out must be after check-in" } });
    if (!RESERVATION_SOURCES.includes(data.source as (typeof RESERVATION_SOURCES)[number])) throw new AppError("Invalid source", "VALIDATION");
    const status = data.status ?? "CONFIRMED";
    if (!["INQUIRY", "PENDING", "CONFIRMED"].includes(status)) throw new AppError("Invalid status", "VALIDATION");
    const adults = Math.max(1, Math.round(data.adults));
    const children = Math.max(0, Math.round(data.children ?? 0));
    const discount = Math.max(0, data.discount ?? 0);
    const deposit = Math.max(0, data.deposit ?? 0);
    const amountPaid = Math.max(0, data.amountPaid ?? 0);
    const settings = await getSettings(ctx);
    const [apartment, customer] = await Promise.all([ctx.db.get(data.apartmentId), ctx.db.get(data.customerId)]);
    if (!apartment || apartment.deletedAt) throw new AppError("Apartment not found", "NOT_FOUND");
    if (!customer || customer.deletedAt) throw new AppError("Customer not found", "NOT_FOUND");
    if (adults + children > apartment.maxGuests) throw new AppError(`This apartment sleeps at most ${apartment.maxGuests} guests.`, "VALIDATION");
    // Risk gate — enforced here regardless of what the UI showed. The screen
    // engine also catches a fresh profile that shares a phone number or ID
    // with a blocked guest. A refused attempt is recorded and reported, so
    // it is returned rather than thrown (a throw would roll the audit back).
    const screen = await screenGuest(ctx, { customerId: customer._id });
    const gate = screen.matches.find((m) => m.riskLevel === "BLOCKED") ?? screen.matches.find((m) => m.riskLevel === "RESTRICTED");
    if (gate && !(can(user, "customers.approve_risky") && data.riskOverride)) {
      const viaLink = gate.id !== customer._id;
      const label = RISK_LEVEL_META[gate.riskLevel as RiskLevel]?.label.toLowerCase() ?? gate.riskLevel.toLowerCase();
      const how = viaLink ? ` — same ${gate.matchedBy.map((x) => (x === "idNumber" ? "ID number" : x === "secondaryPhone" ? "phone" : x)).join(" & ")} as ${gate.fullName} (${gate.code})` : "";
      const message = gate.riskLevel === "BLOCKED" ? `${customer.fullName} is ${label}${how}. ${gate.riskReason ? `Reason: ${gate.riskReason.replace(/\.$/, "")}. ` : ""}No reservation can be created; only an admin can override this.` : `${customer.fullName} is ${label}${how}. A manager must approve this guest before booking.`;
      await audit(ctx, actor, { action: "BLOCKED_GUEST_ATTEMPT", module: "reservations", entityType: "customer", entityId: customer._id, entityLabel: customer.fullName, newValue: { apartment: apartment.code, checkIn: data.checkIn, checkOut: data.checkOut, gate: { code: gate.code, riskLevel: gate.riskLevel, via: gate.matchedBy } }, reason: gate.riskReason ?? undefined, severity: gate.riskLevel === "BLOCKED" ? "CRITICAL" : "WARNING", apartmentId: apartment._id, customerId: customer._id });
      await notify(ctx, { type: "BLOCKED_GUEST_ATTEMPT", title: gate.riskLevel === "BLOCKED" ? "Booking attempt for a blocked guest" : "Booking attempt for a restricted guest", body: `${user.fullName} tried to book ${customer.fullName} in ${apartment.code} (${data.checkIn} → ${data.checkOut})${viaLink ? `, a profile sharing ${gate.fullName}'s ${gate.matchedBy.includes("idNumber") ? "ID" : "phone number"}` : ""}. Refused.`, priority: "CRITICAL", href: `/customers/${customer._id}?tab=risk`, entityType: "customer", entityId: customer._id, actorId: user.id });
      return { blocked: true as const, message };
    }

    const checkIn = parseKey(data.checkIn);
    const checkOut = parseKey(data.checkOut);
    const override = data.nightlyPrice != null && data.nightlyPrice > 0 ? data.nightlyPrice : null;
    const base = computePricing({ basePrice: apartment.basePrice, weekendPrice: apartment.weekendPrice ?? null, weekendDays: settings.weekendDays, checkIn, checkOut, nightlyOverride: null });
    if (override != null && Math.abs(override - base.nightlyPrice) > 0.005 && !can(user, "reservations.change_price")) throw new AppError("You are not allowed to change the price.", "PERMISSION");
    if (discount > 0 && !can(user, "reservations.apply_discount")) throw new AppError("You are not allowed to apply discounts.", "PERMISSION");
    const final = computePricing({ basePrice: apartment.basePrice, weekendPrice: apartment.weekendPrice ?? null, weekendDays: settings.weekendDays, checkIn, checkOut, nightlyOverride: override, discount });
    if (!user.isAdmin && discount > 0 && discount > (final.subtotal * settings.maxDiscountPercentWorker) / 100) throw new AppError(`Discount exceeds the ${settings.maxDiscountPercentWorker}% limit allowed for workers.`, "VALIDATION");
    if (amountPaid > 0 && !can(user, "payments.record")) throw new AppError("You are not allowed to record payments.", "PERMISSION");
    if (data.paymentMethod && !PAYMENT_METHODS.includes(data.paymentMethod as (typeof PAYMENT_METHODS)[number])) throw new AppError("Invalid payment method", "VALIDATION");

    if (status !== "INQUIRY") {
      const conflicts = await findConflicts(ctx, apartment._id, data.checkIn, data.checkOut);
      if (conflicts.length) throw conflictError(conflicts);
      const hold = (await liveHoldsFor(ctx, apartment._id, data.checkIn, data.checkOut)).find((h) => h.userId !== user.id);
      if (hold) throw new AppError(`${hold.userName} is booking ${apartment.code} for these nights right now. Wait a moment or pick another apartment.`, "CONFLICT", { fields: { __hold: hold.userName } });
      // One guest, one apartment per night — unless the worker confirms a second apartment on purpose.
      if (!data.allowSecondStay) {
        const twice = await overlappingStays(ctx, screen.matches.map((m) => m.id), data.checkIn, data.checkOut);
        if (twice.length) {
          const other = await ctx.db.get(twice[0].apartmentId);
          const who = twice[0].customerId === customer._id ? customer.fullName : `${customer.fullName} (same phone or ID as ${screen.matches.find((m) => m.id === twice[0].customerId)?.fullName ?? "another profile"})`;
          throw new AppError(`${who} already has ${twice[0].code} in ${other?.code ?? "another apartment"} for ${twice[0].checkIn} → ${twice[0].checkOut}.`, "CONFLICT", { fields: { __duplicate: JSON.stringify({ id: twice[0].id, code: twice[0].code, apartment: other?.code ?? "", checkIn: twice[0].checkIn, checkOut: twice[0].checkOut }) } });
        }
      }
    }
    const code = await nextCode(ctx, "reservation");
    const recovery = status === "INQUIRY" ? { nights: 0, fromReservationId: null } : await attributeRecovery(ctx, apartment._id, data.checkIn, data.checkOut);
    const now = Date.now();
    const id = await ctx.db.insert("reservations", {
      code,
      customerId: customer._id,
      apartmentId: apartment._id,
      checkIn: data.checkIn,
      checkOut: data.checkOut,
      originalCheckIn: data.checkIn,
      originalCheckOut: data.checkOut,
      recoveredNights: recovery.nights,
      recoveredFromReservationId: recovery.fromReservationId ?? undefined,
      nights: final.nights,
      adults,
      children,
      source: data.source,
      status,
      nightlyPrice: final.nightlyPrice,
      discount: final.discount,
      totalAmount: final.total,
      deposit,
      amountPaid,
      paymentMethod: amountPaid > 0 ? (data.paymentMethod ?? "CASH") : undefined,
      createdById: user.id,
      assignedToId: data.assignedToId ?? undefined,
      internalNotes: data.internalNotes || undefined,
      customerRequests: data.customerRequests || undefined,
      externalRef: data.externalRef || undefined,
      earlyCheckout: false,
      releasedNights: 0,
      segmentIndex: 1,
      createdAt: now,
      updatedAt: now,
    });
    await history(ctx, id, { type: "CREATED", newValue: { status, apartment: apartment.code, checkIn: data.checkIn, checkOut: data.checkOut, total: final.total }, performedById: user.id });
    if (amountPaid > 0) {
      await ctx.db.insert("payments", { code: await nextCode(ctx, "payment"), reservationId: id, customerId: customer._id, amount: amountPaid, type: deposit > 0 && amountPaid <= deposit ? "DEPOSIT" : "PAYMENT", method: data.paymentMethod ?? "CASH", paidAt: now, recordedById: user.id, notes: "Recorded at reservation creation" });
    }
    await audit(ctx, actor, { action: "RESERVATION_CREATED", module: "reservations", entityType: "reservation", entityId: id, entityLabel: code, newValue: { apartment: apartment.code, customer: customer.fullName, checkIn: data.checkIn, checkOut: data.checkOut, total: final.total, status, source: data.source, recoveredNights: recovery.nights }, reservationId: id, apartmentId: apartment._id, customerId: customer._id });
    if (status !== "INQUIRY") {
      await inventoryEvent(ctx, actor, { apartmentId: apartment._id, action: "RESERVATION_CREATED", startDate: data.checkIn, endDate: data.checkOut, previousState: "AVAILABLE", newState: "RESERVED", reservationId: id, source: data.source, estimatedValue: final.total });
      if (recovery.nights > 0) await inventoryEvent(ctx, actor, { apartmentId: apartment._id, action: "REBOOKED", startDate: data.checkIn, endDate: data.checkOut, previousState: "RELEASED", newState: "RESERVED", reason: `${recovery.nights} previously released night${recovery.nights > 1 ? "s" : ""} re-sold`, reservationId: id, estimatedValue: recovery.nights * final.nightlyPrice });
    }
    if (override != null && Math.abs(override - base.nightlyPrice) > 0.005 && !user.isAdmin) {
      await audit(ctx, actor, { action: "PRICE_CHANGED", module: "reservations", entityType: "reservation", entityId: id, entityLabel: code, previousValue: { nightlyPrice: base.nightlyPrice }, newValue: { nightlyPrice: override }, reservationId: id, apartmentId: apartment._id, customerId: customer._id });
      await notify(ctx, { type: "PRICE_CHANGED", title: "Price changed by worker", body: `${user.fullName} set ${code} at ${fmtMoney(override, settings.currency)}/night (default ${fmtMoney(base.nightlyPrice, settings.currency)}).`, href: `/reservations/${id}`, actorId: user.id, entityType: "reservation", entityId: id });
    }
    if (discount > 0) await audit(ctx, actor, { action: "DISCOUNT_APPLIED", module: "reservations", entityType: "reservation", entityId: id, entityLabel: code, newValue: { discount, percent: Math.round((discount / Math.max(1, final.subtotal)) * 1000) / 10 }, reservationId: id, apartmentId: apartment._id, customerId: customer._id });
    if (gate) await audit(ctx, actor, { action: "RISKY_RESERVATION_APPROVAL", module: "reservations", entityType: "reservation", entityId: id, entityLabel: code, newValue: { riskLevel: gate.riskLevel, via: gate.matchedBy, gateCode: gate.code }, reason: gate.riskReason ?? undefined, severity: "WARNING", reservationId: id, apartmentId: apartment._id, customerId: customer._id });
    if (data.allowSecondStay) await audit(ctx, actor, { action: "SECOND_STAY_CONFIRMED", module: "reservations", entityType: "reservation", entityId: id, entityLabel: code, newValue: { checkIn: data.checkIn, checkOut: data.checkOut }, reservationId: id, apartmentId: apartment._id, customerId: customer._id });
    for (const h of await ctx.db.query("bookingHolds").withIndex("by_user", (q) => q.eq("userId", user.id)).collect()) await ctx.db.delete(h._id);
    await notify(ctx, { type: "NEW_RESERVATION", title: "New reservation", body: `${user.fullName} created ${code} · ${customer.fullName} · ${apartment.code} · ${data.checkIn} → ${data.checkOut}.`, href: `/reservations/${id}`, actorId: user.id, targetUserIds: [data.assignedToId ?? null], entityType: "reservation", entityId: id });
    if (status === "CONFIRMED") await evaluateCommission(ctx, id, "RESERVATION_CONFIRMED", actor);
    await syncApartmentStatus(ctx, apartment._id, settings.timezone);
    return { id, code };
  },
});

// ── Edit (non-structural fields) ─────────────────────────────
export const edit = mutation({
  args: {
    id: v.id("reservations"),
    adults: v.optional(v.number()),
    children: v.optional(v.number()),
    source: v.optional(v.string()),
    assignedToId: v.optional(v.union(v.id("users"), v.null())),
    internalNotes: v.optional(v.union(v.string(), v.null())),
    customerRequests: v.optional(v.union(v.string(), v.null())),
    externalRef: v.optional(v.union(v.string(), v.null())),
    deposit: v.optional(v.number()),
  },
  returns: v.null(),
  handler: async (ctx, { id, ...data }) => {
    const user = await assertPermission(ctx, "reservations.edit");
    const { r, apartment } = await loadReservation(ctx, id);
    if (CLOSED.includes(r.status) && !user.isAdmin) throw new AppError("This reservation is closed.", "VALIDATION");
    if ((data.adults ?? r.adults) + (data.children ?? r.children) > apartment.maxGuests) throw new AppError(`This apartment sleeps at most ${apartment.maxGuests} guests.`, "VALIDATION");
    const prev: Record<string, unknown> = {};
    const next: Record<string, unknown> = {};
    for (const k of Object.keys(data) as (keyof typeof data)[]) {
      const cur = (r as Record<string, unknown>)[k] ?? null;
      const val = data[k] === undefined ? undefined : data[k];
      if (val !== undefined && val !== cur) {
        prev[k] = cur;
        next[k] = val;
      }
    }
    if (!Object.keys(next).length) return null;
    const patch: Record<string, unknown> = { updatedAt: Date.now() };
    for (const [k, val] of Object.entries(next)) patch[k] = val === null ? undefined : val;
    await ctx.db.patch(id, patch);
    await history(ctx, id, { type: "NOTE", previousValue: prev, newValue: next, performedById: user.id });
    await audit(ctx, actorLite(user), { action: "RESERVATION_EDITED", module: "reservations", entityType: "reservation", entityId: id, entityLabel: r.code, previousValue: prev, newValue: next, reservationId: id, apartmentId: r.apartmentId, customerId: r.customerId });
    if (next.assignedToId) await notify(ctx, { type: "TASK_ASSIGNED", title: "Reservation assigned to you", body: `${user.fullName} assigned ${r.code} to you.`, href: `/reservations/${id}`, targetUserIds: [next.assignedToId as Id<"users">], actorId: user.id });
    return null;
  },
});

// ── Status transitions ───────────────────────────────────────
const ALLOWED: Record<string, string[]> = {
  INQUIRY: ["PENDING", "CONFIRMED", "CANCELLED"],
  PENDING: ["CONFIRMED", "CANCELLED", "INQUIRY"],
  CONFIRMED: ["CHECKED_IN", "CANCELLED", "NO_SHOW", "PENDING"],
  CHECKED_IN: ["CHECKED_OUT"],
  CHECKED_OUT: [],
  CANCELLED: ["PENDING", "CONFIRMED"],
  NO_SHOW: ["CONFIRMED"],
};

export const changeStatus = mutation({
  args: { id: v.id("reservations"), status: v.string(), reason: v.optional(v.string()) },
  returns: v.null(),
  handler: async (ctx, { id, status, reason }) => {
    const perm = status === "CANCELLED" ? "reservations.cancel" : status === "CHECKED_IN" ? "reservations.checkin" : status === "CHECKED_OUT" ? "reservations.checkout" : "reservations.edit";
    const user = await assertPermission(ctx, perm);
    if (status === "CHECKED_IN" || status === "CHECKED_OUT") throw new AppError("Use the check-in / check-out workflow.", "VALIDATION");
    const settings = await getSettings(ctx);
    const { r, apartment, customer } = await loadReservation(ctx, id);
    if (!ALLOWED[r.status]?.includes(status)) throw new AppError(`Cannot change status from ${r.status.toLowerCase()} to ${status.toLowerCase()}.`, "VALIDATION");
    if (status === "CANCELLED" && !reason) throw new AppError("A reason is required to cancel.", "VALIDATION");
    if (["PENDING", "CONFIRMED"].includes(status) && ["CANCELLED", "NO_SHOW", "INQUIRY"].includes(r.status)) {
      const conflicts = await findConflicts(ctx, r.apartmentId, r.checkIn, r.checkOut, { excludeReservationId: r._id });
      if (conflicts.length) throw conflictError(conflicts);
    }
    await ctx.db.patch(id, { status, cancelledAt: status === "CANCELLED" ? Date.now() : status === "CONFIRMED" || status === "PENDING" ? undefined : r.cancelledAt, cancelReason: status === "CANCELLED" ? reason : r.cancelReason, updatedAt: Date.now() });
    await history(ctx, id, { type: status === "CANCELLED" ? "CANCELLED" : "STATUS_CHANGED", previousValue: r.status, newValue: status, reason, performedById: user.id });
    const actor = actorLite(user);
    await audit(ctx, actor, { action: status === "CANCELLED" ? "RESERVATION_CANCELLED" : "RESERVATION_STATUS_CHANGED", module: "reservations", entityType: "reservation", entityId: id, entityLabel: r.code, previousValue: r.status, newValue: status, reason, reservationId: id, apartmentId: r.apartmentId, customerId: r.customerId });
    if (status === "CANCELLED" || status === "NO_SHOW") {
      await cancelCommissionsFor(ctx, r._id, status === "CANCELLED" ? "Reservation cancelled" : "Guest did not show up", settings, actor);
      await notify(ctx, { type: "RESERVATION_CANCELLED", title: status === "CANCELLED" ? "Reservation cancelled" : "No-show recorded", body: `${user.fullName} marked ${r.code} (${customer.fullName}, ${apartment.code}) as ${status.toLowerCase().replace("_", " ")}${reason ? ": " + reason : ""}.`, href: `/reservations/${id}`, actorId: user.id, targetUserIds: [r.assignedToId, r.createdById] });
    } else if (status === "CONFIRMED") {
      await evaluateCommission(ctx, r._id, "RESERVATION_CONFIRMED", actor);
      await notify(ctx, { type: "RESERVATION_MODIFIED", title: "Reservation confirmed", body: `${r.code} for ${customer.fullName} is now confirmed.`, href: `/reservations/${id}`, actorId: user.id, targetUserIds: [r.assignedToId] });
    }
    await syncApartmentStatus(ctx, r.apartmentId, settings.timezone);
    return null;
  },
});

// ── Change apartment (history preserved) ─────────────────────
export const changeApartment = mutation({
  args: { id: v.id("reservations"), apartmentId: v.id("apartments"), reason: v.optional(v.string()), recalculate: v.boolean() },
  returns: v.object({ priceDifference: v.number() }),
  handler: async (ctx, input) => {
    const user = await assertPermission(ctx, "reservations.change_apartment");
    const settings = await getSettings(ctx);
    const { r, apartment, customer } = await loadReservation(ctx, input.id);
    if (CLOSED.includes(r.status)) throw new AppError("This reservation is closed.", "VALIDATION");
    if (r.apartmentId === input.apartmentId) throw new AppError("Choose a different apartment.", "VALIDATION");
    const target = await ctx.db.get(input.apartmentId);
    if (!target || target.deletedAt || !target.isActive) throw new AppError("Apartment not found", "NOT_FOUND");
    if (r.adults + r.children > target.maxGuests) throw new AppError(`${target.code} sleeps at most ${target.maxGuests} guests.`, "VALIDATION");
    const today = todayKey(settings.timezone);
    const from = r.status === "CHECKED_IN" && today > r.checkIn ? today : r.checkIn;
    let priceDifference = 0;
    let newTotal = r.totalAmount;
    let newNightly = r.nightlyPrice;
    if (input.recalculate) {
      if (!can(user, "reservations.change_price")) throw new AppError("You are not allowed to recalculate the price.", "PERMISSION");
      const p = computePricing({ basePrice: target.basePrice, weekendPrice: target.weekendPrice ?? null, weekendDays: settings.weekendDays, checkIn: parseKey(r.checkIn), checkOut: parseKey(r.checkOut), discount: r.discount });
      newTotal = p.total;
      newNightly = p.nightlyPrice;
      priceDifference = Math.round((newTotal - r.totalAmount) * 100) / 100;
    }
    const conflicts = await findConflicts(ctx, target._id, from, r.checkOut, { excludeReservationId: r._id });
    if (conflicts.length) throw conflictError(conflicts);
    await ctx.db.patch(r._id, { apartmentId: target._id, totalAmount: newTotal, nightlyPrice: newNightly, updatedAt: Date.now() });
    await history(ctx, r._id, { type: "APARTMENT_CHANGED", fromApartmentId: r.apartmentId, toApartmentId: target._id, previousValue: { apartment: apartment.code, total: r.totalAmount }, newValue: { apartment: target.code, total: newTotal }, priceDifference, reason: input.reason, performedById: user.id });
    if (r.status === "CHECKED_IN") {
      await ctx.db.patch(r.apartmentId, { cleaningStatus: "NEEDS_CLEANING", status: "CLEANING", updatedAt: Date.now() });
      await ctx.db.insert("cleaningTasks", { apartmentId: r.apartmentId, reservationId: r._id, status: "NEEDS_CLEANING", scheduledFor: today, notes: `Guest moved to ${target.code}`, createdAt: Date.now() });
      await ctx.db.patch(target._id, { status: "OCCUPIED", updatedAt: Date.now() });
    }
    const actor = actorLite(user);
    await inventoryEvent(ctx, actor, { apartmentId: r.apartmentId, action: "APARTMENT_CHANGED", startDate: from, endDate: r.checkOut, previousState: r.status === "CHECKED_IN" ? "OCCUPIED" : "RESERVED", newState: "AVAILABLE", reason: input.reason ?? `Moved to ${target.code}`, reservationId: r._id });
    await inventoryEvent(ctx, actor, { apartmentId: target._id, action: "APARTMENT_CHANGED", startDate: from, endDate: r.checkOut, previousState: "AVAILABLE", newState: r.status === "CHECKED_IN" ? "OCCUPIED" : "RESERVED", reason: input.reason ?? `Moved from ${apartment.code}`, reservationId: r._id, estimatedValue: newTotal });
    await audit(ctx, actor, { action: "APARTMENT_CHANGED", module: "reservations", entityType: "reservation", entityId: r._id, entityLabel: r.code, previousValue: { apartment: apartment.code, total: r.totalAmount }, newValue: { apartment: target.code, total: newTotal, priceDifference }, reason: input.reason, reservationId: r._id, apartmentId: target._id, customerId: r.customerId });
    await notify(ctx, { type: "RESERVATION_MODIFIED", title: "Apartment changed", body: `${user.fullName} moved ${r.code} (${customer.fullName}) from ${apartment.code} to ${target.code}${priceDifference ? ` · ${fmtMoney(priceDifference, settings.currency, { signed: true })}` : ""}.`, href: `/reservations/${r._id}`, actorId: user.id, targetUserIds: [r.assignedToId, r.createdById] });
    await syncApartmentStatus(ctx, r.apartmentId, settings.timezone);
    await syncApartmentStatus(ctx, target._id, settings.timezone);
    return { priceDifference };
  },
});

// ── Change dates ─────────────────────────────────────────────
export const changeDates = mutation({
  args: { id: v.id("reservations"), checkIn: dayArg, checkOut: dayArg, reason: v.optional(v.string()), recalculate: v.boolean() },
  returns: v.object({ priceDifference: v.number() }),
  handler: async (ctx, input) => {
    const user = await assertPermission(ctx, "reservations.change_dates");
    const settings = await getSettings(ctx);
    assertDay(input.checkIn, "check-in");
    assertDay(input.checkOut, "check-out");
    if (input.checkOut <= input.checkIn) throw new AppError("Check-out must be after check-in.", "VALIDATION");
    const { r, apartment } = await loadReservation(ctx, input.id);
    if (CLOSED.includes(r.status)) throw new AppError("This reservation is closed.", "VALIDATION");
    if (r.status === "CHECKED_IN" && input.checkIn !== r.checkIn) throw new AppError("Guest is already checked in — only the check-out date can change.", "VALIDATION");
    const nights = nightsBetweenKeys(input.checkIn, input.checkOut);
    let total: number;
    let nightly = r.nightlyPrice;
    if (input.recalculate) {
      if (!can(user, "reservations.change_price")) throw new AppError("You are not allowed to recalculate the price.", "PERMISSION");
      const p = computePricing({ basePrice: apartment.basePrice, weekendPrice: apartment.weekendPrice ?? null, weekendDays: settings.weekendDays, checkIn: parseKey(input.checkIn), checkOut: parseKey(input.checkOut), nightlyOverride: Math.abs(r.nightlyPrice - apartment.basePrice) > 0.5 && !apartment.weekendPrice ? r.nightlyPrice : null, discount: r.discount });
      total = p.total;
      nightly = p.nightlyPrice;
    } else total = Math.max(0, Math.round(r.nightlyPrice * nights * 100) / 100 - r.discount);
    const priceDifference = Math.round((total - r.totalAmount) * 100) / 100;
    const conflicts = await findConflicts(ctx, r.apartmentId, input.checkIn, input.checkOut, { excludeReservationId: r._id });
    if (conflicts.length) throw conflictError(conflicts);
    await ctx.db.patch(r._id, { checkIn: input.checkIn, checkOut: input.checkOut, nights, totalAmount: total, nightlyPrice: nightly, updatedAt: Date.now() });
    await history(ctx, r._id, { type: "DATES_CHANGED", previousValue: { checkIn: r.checkIn, checkOut: r.checkOut, total: r.totalAmount }, newValue: { checkIn: input.checkIn, checkOut: input.checkOut, total }, priceDifference, reason: input.reason, performedById: user.id });
    await audit(ctx, actorLite(user), { action: "DATES_CHANGED", module: "reservations", entityType: "reservation", entityId: r._id, entityLabel: r.code, previousValue: { checkIn: r.checkIn, checkOut: r.checkOut, total: r.totalAmount }, newValue: { checkIn: input.checkIn, checkOut: input.checkOut, total }, reason: input.reason, reservationId: r._id, apartmentId: r.apartmentId, customerId: r.customerId });
    await notify(ctx, { type: "RESERVATION_MODIFIED", title: "Dates changed", body: `${user.fullName} changed ${r.code} to ${input.checkIn} → ${input.checkOut}.`, href: `/reservations/${r._id}`, actorId: user.id, targetUserIds: [r.assignedToId, r.createdById] });
    await syncApartmentStatus(ctx, r.apartmentId, settings.timezone);
    return { priceDifference };
  },
});

// ── Price / discount ─────────────────────────────────────────
export const changePrice = mutation({
  args: { id: v.id("reservations"), nightlyPrice: v.optional(v.number()), discount: v.optional(v.number()), reason: v.optional(v.string()) },
  returns: v.null(),
  handler: async (ctx, input) => {
    const user = await assertPermission(ctx, "reservations.change_price", "reservations.apply_discount");
    const settings = await getSettings(ctx);
    const { r } = await loadReservation(ctx, input.id);
    if (CLOSED.includes(r.status)) throw new AppError("This reservation is closed.", "VALIDATION");
    const nightly = input.nightlyPrice ?? r.nightlyPrice;
    const discount = input.discount ?? r.discount;
    if (nightly !== r.nightlyPrice && !can(user, "reservations.change_price")) throw new AppError("You are not allowed to change the price.", "PERMISSION");
    if (discount !== r.discount && !can(user, "reservations.apply_discount")) throw new AppError("You are not allowed to apply discounts.", "PERMISSION");
    if (nightly < 0 || discount < 0) throw new AppError("Amounts must be positive.", "VALIDATION");
    const subtotal = Math.round(nightly * r.nights * 100) / 100;
    if (!user.isAdmin && discount > (subtotal * settings.maxDiscountPercentWorker) / 100) throw new AppError(`Discount exceeds the ${settings.maxDiscountPercentWorker}% limit allowed for workers.`, "VALIDATION");
    const total = Math.max(0, subtotal - discount);
    const type = nightly !== r.nightlyPrice ? "PRICE_CHANGED" : "DISCOUNT_APPLIED";
    await ctx.db.patch(r._id, { nightlyPrice: nightly, discount, totalAmount: total, updatedAt: Date.now() });
    await history(ctx, r._id, { type, previousValue: { nightlyPrice: r.nightlyPrice, discount: r.discount, total: r.totalAmount }, newValue: { nightlyPrice: nightly, discount, total }, priceDifference: total - r.totalAmount, reason: input.reason, performedById: user.id });
    await audit(ctx, actorLite(user), { action: type, module: "reservations", entityType: "reservation", entityId: r._id, entityLabel: r.code, previousValue: { nightlyPrice: r.nightlyPrice, discount: r.discount, total: r.totalAmount }, newValue: { nightlyPrice: nightly, discount, total }, reason: input.reason, reservationId: r._id, apartmentId: r.apartmentId, customerId: r.customerId });
    if (!user.isAdmin) await notify(ctx, { type: "PRICE_CHANGED", title: "Price changed by worker", body: `${user.fullName} changed ${r.code}: ${fmtMoney(r.totalAmount, settings.currency)} → ${fmtMoney(total, settings.currency)}${input.reason ? ` (${input.reason})` : ""}.`, href: `/reservations/${r._id}`, actorId: user.id });
    return null;
  },
});

// ── Check-in readiness ───────────────────────────────────────
export async function readinessFor(ctx: QueryCtx | MutationCtx, r: Doc<"reservations">) {
  const [apartment, contracts, docs] = await Promise.all([ctx.db.get(r.apartmentId), ctx.db.query("contracts").withIndex("by_reservation", (q) => q.eq("reservationId", r._id)).collect(), ctx.db.query("documents").withIndex("by_customer", (q) => q.eq("customerId", r.customerId)).collect()]);
  const contract = contracts.sort((a, b) => b.version - a.version)[0];
  const idDocs = docs.filter((d) => !d.deletedAt && (d.category === "ID_FRONT" || d.category === "ID_BACK"));
  return {
    documentsUploaded: idDocs.length >= 1,
    contractGenerated: !!contract,
    contractSigned: contract?.status === "SIGNED",
    depositCollected: r.deposit === 0 || r.amountPaid >= r.deposit,
    paymentOk: r.amountPaid >= r.totalAmount,
    apartmentReady: apartment?.cleaningStatus === "CLEAN" || apartment?.cleaningStatus === "READY",
    apartmentStatus: apartment?.status ?? "AVAILABLE",
    remaining: Math.max(0, r.totalAmount - r.amountPaid),
  };
}

export const readiness = query({
  args: { id: v.id("reservations") },
  returns: v.union(v.null(), v.object({ documentsUploaded: v.boolean(), contractGenerated: v.boolean(), contractSigned: v.boolean(), depositCollected: v.boolean(), paymentOk: v.boolean(), apartmentReady: v.boolean(), apartmentStatus: v.string(), remaining: v.number() })),
  handler: async (ctx, { id }) => {
    const actor = await requireActor(ctx);
    const r = await ctx.db.get(id);
    if (!r || !canSeeReservation(actor, r)) return null;
    return readinessFor(ctx, r);
  },
});

// ── Check-in ─────────────────────────────────────────────────
const checklistIn = v.object({ identityVerified: v.boolean(), documentsUploaded: v.boolean(), contractGenerated: v.boolean(), contractSigned: v.boolean(), depositCollected: v.boolean(), paymentOk: v.boolean(), apartmentReady: v.boolean(), cleaningDone: v.boolean() });

export const checkIn = mutation({
  args: { id: v.id("reservations"), checklist: checklistIn, notes: v.optional(v.string()), force: v.optional(v.boolean()) },
  returns: v.null(),
  handler: async (ctx, input) => {
    const user = await assertPermission(ctx, "reservations.checkin");
    const settings = await getSettings(ctx);
    const { r, apartment, customer } = await loadReservation(ctx, input.id);
    if (!["CONFIRMED", "PENDING"].includes(r.status)) throw new AppError(`Cannot check in a ${r.status.toLowerCase().replace("_", " ")} reservation.`, "VALIDATION");
    const ready = await readinessFor(ctx, r);
    const c = input.checklist;
    const blockers: string[] = [];
    if (!c.identityVerified) blockers.push("Customer identity not verified");
    if (!ready.documentsUploaded) blockers.push("ID documents missing");
    if (!ready.contractGenerated) blockers.push("Contract not generated");
    if (!c.depositCollected && r.deposit > 0) blockers.push("Deposit not collected");
    if (blockers.length && !(input.force && user.isAdmin)) throw new AppError(`Cannot check in: ${blockers.join(", ")}.`, "VALIDATION");
    const occupied = (await ctx.db.query("reservations").withIndex("by_status_checkIn", (q) => q.eq("status", "CHECKED_IN")).collect()).find((x) => x.apartmentId === r.apartmentId && x._id !== r._id);
    if (occupied) throw new AppError(`${apartment.code} is still occupied by ${occupied.code}. Check them out first or move this reservation.`, "CONFLICT");
    const now = Date.now();
    await ctx.db.patch(r._id, { status: "CHECKED_IN", checkedInAt: now, checkedInById: user.id, checkInNotes: input.notes || undefined, updatedAt: now });
    await history(ctx, r._id, { type: "CHECKED_IN", previousValue: r.status, newValue: "CHECKED_IN", reason: input.notes, performedById: user.id });
    await ctx.db.patch(r.apartmentId, { status: "OCCUPIED", updatedAt: now });
    for (const t of await ctx.db.query("tasks").withIndex("by_reservation", (q) => q.eq("reservationId", r._id)).collect()) if (t.type === "CHECK_IN" && (t.status === "TODO" || t.status === "IN_PROGRESS")) await ctx.db.patch(t._id, { status: "COMPLETED", completedAt: now, updatedAt: now });
    const actor = actorLite(user);
    await inventoryEvent(ctx, actor, { apartmentId: r.apartmentId, action: "CHECKIN", startDate: r.checkIn, endDate: r.checkOut, previousState: "RESERVED", newState: "OCCUPIED", reservationId: r._id });
    await audit(ctx, actor, { action: "CHECKED_IN", module: "reservations", entityType: "reservation", entityId: r._id, entityLabel: r.code, newValue: { checklist: c, force: !!input.force }, reason: input.notes, reservationId: r._id, apartmentId: r.apartmentId, customerId: r.customerId });
    if (!ready.documentsUploaded) await notify(ctx, { type: "CUSTOMER_ID_MISSING", title: "Check-in without ID documents", body: `${user.fullName} checked in ${r.code} (${customer.fullName}) without ID documents on file.`, href: `/customers/${r.customerId}`, actorId: user.id });
    if (!ready.contractGenerated) await notify(ctx, { type: "CONTRACT_MISSING", title: "Check-in without contract", body: `${user.fullName} checked in ${r.code} without a generated contract.`, href: `/reservations/${r._id}`, actorId: user.id });
    if (ready.remaining > 0) await notify(ctx, { type: "PAYMENT_DUE", title: "Balance outstanding at check-in", body: `${r.code} (${customer.fullName}) still owes ${fmtMoney(ready.remaining, settings.currency)}.`, href: `/reservations/${r._id}`, actorId: user.id, targetUserIds: [r.assignedToId] });
    await evaluateCommission(ctx, r._id, "CHECK_IN", actor);
    return null;
  },
});

// ── Check-out ────────────────────────────────────────────────
const checklistOut = v.object({ guestLeft: v.boolean(), keysReturned: v.boolean(), inspected: v.boolean(), damageReported: v.boolean(), balanceCollected: v.boolean(), cleaningRequested: v.boolean(), depositRefunded: v.boolean() });

export const checkOut = mutation({
  args: { id: v.id("reservations"), checklist: checklistOut, notes: v.optional(v.string()), damageNotes: v.optional(v.string()), refundDeposit: v.optional(v.number()) },
  returns: v.null(),
  handler: async (ctx, input) => {
    const user = await assertPermission(ctx, "reservations.checkout");
    const settings = await getSettings(ctx);
    const { r, apartment, customer } = await loadReservation(ctx, input.id);
    if (r.status !== "CHECKED_IN") throw new AppError("Only checked-in reservations can be checked out.", "VALIDATION");
    const c = input.checklist;
    if (!c.guestLeft || !c.keysReturned) throw new AppError("Confirm the guest has left and keys are returned.", "VALIDATION");
    const remaining = Math.max(0, r.totalAmount - r.amountPaid);
    if (remaining > 0 && !c.balanceCollected && !user.isAdmin) throw new AppError(`Remaining balance of ${fmtMoney(remaining, settings.currency)} must be collected (record a payment) before check-out.`, "VALIDATION");
    const today = todayKey(settings.timezone);
    const now = Date.now();
    await ctx.db.patch(r._id, { status: "CHECKED_OUT", checkedOutAt: now, checkedOutById: user.id, checkOutNotes: [input.notes, input.damageNotes ? `Damage: ${input.damageNotes}` : null].filter(Boolean).join("\n") || undefined, updatedAt: now });
    await history(ctx, r._id, { type: "CHECKED_OUT", previousValue: "CHECKED_IN", newValue: "CHECKED_OUT", reason: input.notes, performedById: user.id });
    if (input.refundDeposit && input.refundDeposit > 0) await ctx.db.insert("payments", { code: await nextCode(ctx, "payment"), reservationId: r._id, customerId: r.customerId, amount: -Math.abs(input.refundDeposit), type: "DEPOSIT_REFUND", method: r.paymentMethod ?? "CASH", paidAt: now, recordedById: user.id, notes: "Deposit refunded at check-out" });
    await ctx.db.patch(r.apartmentId, { status: "CLEANING", cleaningStatus: "NEEDS_CLEANING", updatedAt: now });
    if (settings.autoCreateCleaningTask) {
      const cleanerRole = await ctx.db.query("roles").withIndex("by_key", (q) => q.eq("key", "CLEANER")).unique();
      const cleaner = cleanerRole ? (await ctx.db.query("users").withIndex("by_role", (q) => q.eq("roleId", cleanerRole._id)).collect()).find((u) => u.status === "ACTIVE" && !u.deletedAt) : null;
      await ctx.db.insert("cleaningTasks", { apartmentId: r.apartmentId, reservationId: r._id, assigneeId: cleaner?._id, status: "NEEDS_CLEANING", scheduledFor: today, notes: `Turnover cleaning after ${r.code}`, createdAt: now });
      await ctx.db.insert("tasks", { title: `Cleaning ${apartment.code} after ${r.code}`, type: "CLEANING", apartmentId: r.apartmentId, reservationId: r._id, assigneeId: cleaner?._id, createdById: user.id, priority: "HIGH", dueDate: today, status: "TODO", createdAt: now, updatedAt: now });
    }
    const actor = actorLite(user);
    if (c.damageReported && input.damageNotes) {
      await ctx.db.insert("maintenanceTickets", { code: await nextCode(ctx, "maintenance"), apartmentId: r.apartmentId, title: `Damage reported at check-out of ${r.code}`, category: "GENERAL", priority: "HIGH", description: input.damageNotes, reportedById: user.id, cost: 0, status: "REPORTED", blocksApartment: false, createdAt: now, updatedAt: now });
      await notify(ctx, { type: "MAINTENANCE_ALERT", title: "Damage reported at check-out", body: `${apartment.code}: ${input.damageNotes}`, href: "/maintenance", actorId: user.id });
    }
    for (const t of await ctx.db.query("tasks").withIndex("by_reservation", (q) => q.eq("reservationId", r._id)).collect()) if (t.type === "CHECK_OUT" && (t.status === "TODO" || t.status === "IN_PROGRESS")) await ctx.db.patch(t._id, { status: "COMPLETED", completedAt: now, updatedAt: now });
    await inventoryEvent(ctx, actor, { apartmentId: r.apartmentId, action: "CHECKOUT", startDate: r.checkIn, endDate: r.checkOut, previousState: "OCCUPIED", newState: "CLEANING", reservationId: r._id });
    await audit(ctx, actor, { action: "CHECKED_OUT", module: "reservations", entityType: "reservation", entityId: r._id, entityLabel: r.code, newValue: { checklist: c, refundDeposit: input.refundDeposit ?? 0 }, reason: input.notes, reservationId: r._id, apartmentId: r.apartmentId, customerId: r.customerId });
    await notify(ctx, { type: "APARTMENT_NEEDS_CLEANING", title: `${apartment.code} needs cleaning`, body: `${customer.fullName} checked out of ${apartment.name}. Turnover cleaning required.`, href: "/cleaning", actorId: user.id });
    return null;
  },
});

export const remove = mutation({
  args: { id: v.id("reservations"), reason: v.string() },
  returns: v.null(),
  handler: async (ctx, { id, reason }) => {
    const user = await assertPermission(ctx, "reservations.delete");
    const r = await ctx.db.get(id);
    if (!r) throw new AppError("Reservation not found", "NOT_FOUND");
    const [payments, commissions] = await Promise.all([ctx.db.query("payments").withIndex("by_reservation", (q) => q.eq("reservationId", id)).collect(), ctx.db.query("commissions").withIndex("by_reservation", (q) => q.eq("reservationId", id)).collect()]);
    if (payments.length || commissions.some((c) => c.status === "PAID")) throw new AppError("Reservations with payments or paid commissions cannot be deleted. Cancel it instead.", "VALIDATION");
    await audit(ctx, actorLite(user), { action: "RESERVATION_CANCELLED", module: "reservations", entityType: "reservation", entityId: id, entityLabel: r.code, previousValue: { status: r.status, total: r.totalAmount }, newValue: "DELETED", reason, apartmentId: r.apartmentId, customerId: r.customerId, severity: "WARNING" });
    for (const c of commissions) await ctx.db.delete(c._id);
    for (const t of await ctx.db.query("tasks").withIndex("by_reservation", (q) => q.eq("reservationId", id)).collect()) await ctx.db.delete(t._id);
    for (const h of await ctx.db.query("reservationHistory").withIndex("by_reservation_at", (q) => q.eq("reservationId", id)).collect()) await ctx.db.delete(h._id);
    await ctx.db.delete(id);
    return null;
  },
});

export { effectiveCheckOut };

// ── Read side ────────────────────────────────────────────────
import { reservationRow, withId, loader, userLite } from "./lib/shape";
import { addDaysKey } from "./lib/days";

/** Reservations list page: filters mirror the URL search params. */
export const list = query({
  args: { status: v.optional(v.string()), source: v.optional(v.string()), apartment: v.optional(v.string()), worker: v.optional(v.string()), from: v.optional(v.string()), to: v.optional(v.string()), arriving: v.optional(v.string()), leaving: v.optional(v.string()), upcoming: v.optional(v.string()) },
  returns: v.object({ rows: v.array(v.any()), counts: v.array(v.number()), hasFilter: v.boolean() }),
  handler: async (ctx, sp) => {
    const actor = await assertPermission(ctx, "reservations.view");
    const settings = await getSettings(ctx);
    const today = todayKey(settings.timezone);
    const viewAll = can(actor, "reservations.view_all");
    const hasFilter = !!(sp.status || sp.source || sp.apartment || sp.worker || sp.from || sp.to || sp.arriving || sp.leaving || sp.upcoming);
    let rows: Doc<"reservations">[];
    if (sp.status) rows = await ctx.db.query("reservations").withIndex("by_status_checkIn", (q) => q.eq("status", sp.status!)).order("desc").take(1000);
    else if (sp.arriving === "today") rows = await ctx.db.query("reservations").withIndex("by_checkIn", (q) => q.eq("checkIn", today)).collect();
    else if (sp.leaving === "today") rows = await ctx.db.query("reservations").withIndex("by_checkOut", (q) => q.eq("checkOut", today)).collect();
    else if (sp.upcoming) rows = await ctx.db.query("reservations").withIndex("by_checkIn", (q) => q.gt("checkIn", today).lte("checkIn", addDaysKey(today, 14))).collect();
    else if (hasFilter) rows = await ctx.db.query("reservations").withIndex("by_checkIn").order("desc").take(1500);
    else rows = await ctx.db.query("reservations").withIndex("by_checkOut", (q) => q.gte("checkOut", addDaysKey(today, -90))).collect();
    rows = rows.filter((r) => (!sp.source || r.source === sp.source) && (!sp.apartment || r.apartmentId === sp.apartment) && (!sp.worker || r.createdById === sp.worker || r.assignedToId === sp.worker) && (!sp.to || r.checkIn <= sp.to) && (!sp.from || r.checkOut >= sp.from) && (!sp.arriving || r.checkIn === today) && (!sp.leaving || r.checkOut === today) && (viewAll || r.createdById === actor.id || r.assignedToId === actor.id));
    rows.sort((a, b) => b.checkIn.localeCompare(a.checkIn));
    const [arr, leave, inHouse, pending] = await Promise.all([
      ctx.db.query("reservations").withIndex("by_checkIn", (q) => q.eq("checkIn", today)).collect().then((x) => x.filter((r) => r.status === "CONFIRMED" || r.status === "PENDING").length),
      ctx.db.query("reservations").withIndex("by_checkOut", (q) => q.eq("checkOut", today)).collect().then((x) => x.filter((r) => r.status === "CHECKED_IN").length),
      ctx.db.query("reservations").withIndex("by_status_checkIn", (q) => q.eq("status", "CHECKED_IN")).collect().then((x) => x.length),
      ctx.db.query("reservations").withIndex("by_status_checkIn", (q) => q.eq("status", "PENDING").gte("checkIn", today)).collect().then((x) => x.length),
    ]);
    return { rows: await Promise.all(rows.slice(0, 500).map((r) => reservationRow(ctx, r))), counts: [arr, leave, inHouse, pending], hasFilter };
  },
});

async function historyRows(ctx: QueryCtx | MutationCtx, reservationId: Id<"reservations">, limit?: number) {
  const users = loader(ctx, "users");
  const apts = loader(ctx, "apartments");
  const h = await ctx.db.query("reservationHistory").withIndex("by_reservation_at", (q) => q.eq("reservationId", reservationId)).order("desc").take(limit ?? 200);
  return Promise.all(h.map(async (x) => ({ ...withId(x), createdAt: x.at, performedBy: x.performedById ? { fullName: (await users(x.performedById))?.fullName ?? "" } : null, fromApartment: x.fromApartmentId ? { code: (await apts(x.fromApartmentId))?.code ?? "" } : null, toApartment: x.toApartmentId ? { code: (await apts(x.toApartmentId))?.code ?? "" } : null })));
}

/** Reservation detail page payload. */
export const get = query({
  args: { id: v.id("reservations") },
  returns: v.union(v.null(), v.any()),
  handler: async (ctx, { id }) => {
    const actor = await assertPermission(ctx, "reservations.view");
    const r = await ctx.db.get(id);
    if (!r || !canSeeReservation(actor, r)) return null;
    const users = loader(ctx, "users");
    const [customer, apartment] = await Promise.all([ctx.db.get(r.customerId), ctx.db.get(r.apartmentId)]);
    if (!customer || !apartment) return null;
    const [docsRaw, resCount, history, paymentsRaw, contractsRaw, commissionsRaw, tasksRaw, readiness] = await Promise.all([
      ctx.db.query("documents").withIndex("by_customer", (q) => q.eq("customerId", customer._id)).order("desc").collect(),
      ctx.db.query("reservations").withIndex("by_customer_checkIn", (q) => q.eq("customerId", customer._id)).take(500).then((x) => x.length),
      historyRows(ctx, r._id),
      ctx.db.query("payments").withIndex("by_reservation", (q) => q.eq("reservationId", r._id)).collect(),
      ctx.db.query("contracts").withIndex("by_reservation", (q) => q.eq("reservationId", r._id)).collect(),
      ctx.db.query("commissions").withIndex("by_reservation", (q) => q.eq("reservationId", r._id)).collect(),
      ctx.db.query("tasks").withIndex("by_reservation", (q) => q.eq("reservationId", r._id)).order("desc").collect(),
      readinessFor(ctx, r),
    ]);
    const documents = await Promise.all(docsRaw.filter((d) => !d.deletedAt).map(async (d) => ({ ...withId(d), createdAt: d.at, uploadedBy: { fullName: (await users(d.uploadedById))?.fullName ?? "" } })));
    const payments = await Promise.all(paymentsRaw.sort((a, b) => b.paidAt - a.paidAt).map(async (p) => ({ ...withId(p), recordedBy: { fullName: (await users(p.recordedById))?.fullName ?? "" } })));
    const contracts = await Promise.all(contractsRaw.sort((a, b) => b.version - a.version).map(async (c) => ({ ...withId(c), createdAt: c.at, generatedBy: { fullName: (await users(c.generatedById))?.fullName ?? "" } })));
    const commissions = await Promise.all(commissionsRaw.map(async (c) => ({ ...withId(c), worker: { fullName: (await users(c.workerId))?.fullName ?? "" } })));
    const tasks = await Promise.all(tasksRaw.map(async (t) => ({ ...withId(t), assignee: t.assigneeId ? { fullName: (await users(t.assigneeId))?.fullName ?? "" } : null })));
    const [createdBy, assignedTo, checkedInBy, checkedOutBy] = await Promise.all([userLite(ctx, r.createdById), userLite(ctx, r.assignedToId), userLite(ctx, r.checkedInById), userLite(ctx, r.checkedOutById)]);
    const holds = (await ctx.db.query("apartmentBlocks").withIndex("by_reservation", (q) => q.eq("reservationId", r._id)).collect()).filter((b) => b.type === "HOLD").map((h) => ({ id: h._id, startDate: h.startDate, endDate: h.endDate, pendingApproval: h.pendingApproval, releaseOnCleaning: h.releaseOnCleaning, reason: h.reason ?? null }));
    return { ...withId(r), customer: { ...withId(customer), documents, _count: { reservations: resCount } }, apartment: withId(apartment), createdBy: createdBy!, assignedTo, checkedInBy, checkedOutBy, history, payments, contracts, commissions, tasks, readiness, holds };
  },
});

/** Lightweight title lookup for generateMetadata. */
export const title = query({
  args: { id: v.id("reservations") },
  returns: v.union(v.null(), v.string()),
  handler: async (ctx, { id }) => {
    const actor = await requireActor(ctx);
    const r = await ctx.db.get(id);
    return r && canSeeReservation(actor, r) ? r.code : null;
  },
});

/** Calendar window: everything that overlaps [from, to). */
export const calendar = query({
  args: { from: v.string(), to: v.string() },
  returns: v.object({ apartments: v.array(v.any()), reservations: v.array(v.any()), blocks: v.array(v.any()) }),
  handler: async (ctx, { from, to }) => {
    await assertPermission(ctx, "calendar.view");
    const apartments = (await ctx.db.query("apartments").withIndex("by_active", (q) => q.eq("isActive", true)).collect()).filter((a) => !a.deletedAt).sort((a, b) => a.code.localeCompare(b.code)).map((a) => ({ id: a._id, code: a.code, name: a.name, status: a.status, cleaningStatus: a.cleaningStatus, maxGuests: a.maxGuests, building: a.building ?? null }));
    const rs = (await ctx.db.query("reservations").withIndex("by_checkIn", (q) => q.gte("checkIn", addDaysKey(from, -120)).lt("checkIn", to)).collect()).filter((r) => r.checkOut > from && ["PENDING", "CONFIRMED", "CHECKED_IN", "CHECKED_OUT", "INQUIRY"].includes(r.status));
    const customers = loader(ctx, "customers");
    const reservations = await Promise.all(rs.map(async (r) => { const c = await customers(r.customerId); return { id: r._id, code: r.code, status: r.status, source: r.source, checkIn: r.checkIn, checkOut: r.checkOut, actualCheckOut: r.actualCheckOut ?? null, earlyCheckout: r.earlyCheckout, recoveredNights: r.recoveredNights, nights: r.nights, adults: r.adults, children: r.children, totalAmount: r.totalAmount, amountPaid: r.amountPaid, apartmentId: r.apartmentId, createdById: r.createdById, assignedToId: r.assignedToId ?? null, customer: { fullName: c?.fullName ?? "", phone: c?.phone ?? "" } }; }));
    const blocks = (await ctx.db.query("apartmentBlocks").withIndex("by_start", (q) => q.gte("startDate", addDaysKey(from, -400)).lt("startDate", to)).collect()).filter((b) => b.endDate > from).map((b) => ({ id: b._id, apartmentId: b.apartmentId, startDate: b.startDate, endDate: b.endDate, reason: b.reason ?? null, type: b.type, source: b.source, guestName: b.guestName ?? null, externalRef: b.externalRef ?? null, amount: b.amount ?? null, pendingApproval: b.pendingApproval, releaseOnCleaning: b.releaseOnCleaning, reservationId: b.reservationId ?? null }));
    return { apartments, reservations, blocks };
  },
});

/** Per-night occupancy, arrivals and departures for the phone month/year views (≤ 400 nights). */
export const calendarHeat = query({
  args: { from: v.string(), to: v.string() },
  returns: v.object({ total: v.number(), days: v.array(v.object({ k: v.string(), booked: v.number(), arrivals: v.number(), departures: v.number() })) }),
  handler: async (ctx, { from, to }) => {
    await assertPermission(ctx, "calendar.view");
    if (to <= from || nightsBetweenKeys(from, to) > 400) return { total: 0, days: [] };
    const apts = (await ctx.db.query("apartments").withIndex("by_active", (q) => q.eq("isActive", true)).collect()).filter((a) => !a.deletedAt);
    const ids = new Set(apts.map((a) => a._id));
    const map = new Map<string, { booked: number; arrivals: number; departures: number }>();
    const bump = (k: string, f: "booked" | "arrivals" | "departures") => {
      if (k < from || k >= to) return;
      const e = map.get(k) ?? { booked: 0, arrivals: 0, departures: 0 };
      e[f]++;
      map.set(k, e);
    };
    const rs = (await ctx.db.query("reservations").withIndex("by_checkIn", (q) => q.gte("checkIn", addDaysKey(from, -120)).lt("checkIn", to)).collect()).filter((r) => ids.has(r.apartmentId) && r.checkOut > from && ["PENDING", "CONFIRMED", "CHECKED_IN"].includes(r.status));
    for (const r of rs) {
      const end = r.actualCheckOut && r.actualCheckOut < r.checkOut ? r.actualCheckOut : r.checkOut;
      for (const k of eachNightKey(r.checkIn, end)) bump(k, "booked");
      bump(r.checkIn, "arrivals");
      bump(end, "departures");
    }
    const bs = (await ctx.db.query("apartmentBlocks").withIndex("by_start", (q) => q.gte("startDate", addDaysKey(from, -400)).lt("startDate", to)).collect()).filter((b) => ids.has(b.apartmentId) && b.endDate > from && b.type !== "HOLD");
    for (const b of bs) for (const k of eachNightKey(b.startDate, b.endDate)) bump(k, "booked");
    return { total: apts.length, days: [...map.entries()].map(([k, e]) => ({ k, ...e })).sort((a, b) => a.k.localeCompare(b.k)) };
  },
});

/** Everything the reservation drawer needs. */
export const drawer = query({
  args: { id: v.id("reservations") },
  returns: v.union(v.null(), v.any()),
  handler: async (ctx, { id }) => {
    const actor = await assertPermission(ctx, "reservations.view");
    const r = await ctx.db.get(id);
    if (!r || !canSeeReservation(actor, r)) return null;
    const settings = await getSettings(ctx);
    const showMoney = can(actor, "payments.view") || can(actor, "financials.view_revenue");
    const [customer, apartment, createdBy, assignedTo, contracts, history, readiness] = await Promise.all([ctx.db.get(r.customerId), ctx.db.get(r.apartmentId), userLite(ctx, r.createdById), userLite(ctx, r.assignedToId), ctx.db.query("contracts").withIndex("by_reservation", (q) => q.eq("reservationId", r._id)).collect(), historyRows(ctx, r._id, 6), readinessFor(ctx, r)]);
    if (!customer || !apartment) return null;
    const resCount = (await ctx.db.query("reservations").withIndex("by_customer_checkIn", (q) => q.eq("customerId", customer._id)).take(500)).length;
    const contract = contracts.sort((a, b) => b.version - a.version)[0];
    const holds = (await ctx.db.query("apartmentBlocks").withIndex("by_reservation", (q) => q.eq("reservationId", r._id)).collect()).filter((b) => b.type === "HOLD").map((h) => ({ id: h._id, startDate: h.startDate, endDate: h.endDate, pendingApproval: h.pendingApproval, releaseOnCleaning: h.releaseOnCleaning, reason: h.reason ?? null }));
    return {
      reservation: { ...withId(r), totalAmount: showMoney ? r.totalAmount : 0, amountPaid: showMoney ? r.amountPaid : 0, customer: { id: customer._id, fullName: customer.fullName, phone: customer.phone, email: customer.email ?? null, idNumber: customer.idNumber ?? null, idType: customer.idType ?? null, nationality: customer.nationality ?? null, isBlacklisted: customer.isBlacklisted, _count: { reservations: resCount } }, apartment: { id: apartment._id, code: apartment.code, name: apartment.name, maxGuests: apartment.maxGuests, basePrice: apartment.basePrice, cleaningStatus: apartment.cleaningStatus, status: apartment.status, building: apartment.building ?? null }, createdBy, assignedTo, contracts: contract ? [{ id: contract._id, code: contract.code, status: contract.status, version: contract.version }] : [], history, holds },
      readiness,
      perms: { edit: can(actor, "reservations.edit"), cancel: can(actor, "reservations.cancel"), delete: can(actor, "reservations.delete"), checkin: can(actor, "reservations.checkin"), checkout: can(actor, "reservations.checkout"), changeApartment: can(actor, "reservations.change_apartment"), changeDates: can(actor, "reservations.change_dates"), changePrice: can(actor, "reservations.change_price"), discount: can(actor, "reservations.apply_discount"), payment: can(actor, "payments.record"), contract: can(actor, "contracts.generate"), block: can(actor, "apartments.block") },
      isAdmin: actor.isAdmin,
      currency: settings.currency,
      today: todayKey(settings.timezone),
      showMoney,
      policies: { cancel: settings.cancelReleasePolicy, earlyCheckout: settings.earlyCheckoutReleasePolicy },
    };
  },
});
