/* eslint-disable @typescript-eslint/no-explicit-any -- Convex table-generic helpers; runtime validators enforce the shapes */
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 } from "./lib/access";
import { audit } from "./lib/audit";
import { notify } from "./lib/notify";
import { nextCode } from "./lib/seq";
import { getSettings } from "./lib/settings";
import { addDaysKey, todayKey } from "./lib/days";
import { withId, userLite, loader, isRevenue } from "./lib/shape";
import { normalizePhone, fmtMoney } from "../src/lib/format";
import { ID_TYPES, RISK_LEVELS, INCIDENT_TYPES, INCIDENT_SEVERITIES, VERIFICATION_STATUSES, RISK_LEVEL_META, INCIDENT_TYPE_META, type IncidentType, type RiskLevel } from "../src/lib/domain";
import { screenGuest } from "./lib/screening";

const opt = <T extends import("convex/values").Validator<unknown, "required", string>>(t: T) => v.optional(v.union(t, v.null()));

export const customerArgs = {
  firstName: v.string(),
  lastName: v.string(),
  phone: v.string(),
  secondaryPhone: opt(v.string()),
  email: opt(v.string()),
  nationality: opt(v.string()),
  dateOfBirth: opt(v.string()),
  idType: opt(v.string()),
  idNumber: opt(v.string()),
  idExpiration: opt(v.string()),
  address: opt(v.string()),
  preferredLanguage: v.optional(v.string()),
  notes: opt(v.string()),
};
type CustomerInput = { firstName: string; lastName: string; phone: string; secondaryPhone?: string | null; email?: string | null; nationality?: string | null; dateOfBirth?: string | null; idType?: string | null; idNumber?: string | null; idExpiration?: string | null; address?: string | null; preferredLanguage?: string; notes?: string | null };

export const normName = (s: string) =>
  s
    .normalize("NFD")
    .replace(/[̀-ͯ]/g, "")
    .toLowerCase()
    .replace(/[^a-z\s]/g, "")
    .split(/\s+/)
    .filter(Boolean)
    .sort()
    .join(" ");
export const phoneKeyOf = (p: string) => p.replace(/[^\d]/g, "").slice(-8);

function validate(d: CustomerInput) {
  const fields: Record<string, string> = {};
  if (d.firstName.trim().length < 1) fields.firstName = "First name is required";
  if (d.lastName.trim().length < 1) fields.lastName = "Last name is required";
  if (d.phone.trim().length < 6) fields.phone = "Phone is required";
  if (d.email && !/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(d.email.trim())) fields.email = "Invalid email";
  if (d.idType && !ID_TYPES.includes(d.idType as (typeof ID_TYPES)[number])) fields.idType = "Invalid ID type";
  for (const k of ["dateOfBirth", "idExpiration"] as const) if (d[k] && !/^\d{4}-\d{2}-\d{2}$/.test(d[k]!)) fields[k] = "Invalid date";
  if (Object.keys(fields).length) throw new AppError(Object.values(fields)[0], "VALIDATION", { fields });
}

export function normalise(d: CustomerInput) {
  const firstName = d.firstName.trim();
  const lastName = d.lastName.trim();
  const fullName = `${firstName} ${lastName}`.trim();
  const phone = normalizePhone(d.phone);
  return {
    firstName,
    lastName,
    fullName,
    nameKey: normName(fullName),
    phone,
    phoneKey: phoneKeyOf(phone),
    secondaryPhone: d.secondaryPhone ? normalizePhone(d.secondaryPhone) : undefined,
    email: d.email ? d.email.trim().toLowerCase() : undefined,
    nationality: d.nationality?.trim() || undefined,
    dateOfBirth: d.dateOfBirth || undefined,
    idType: d.idType || undefined,
    idNumber: d.idNumber?.trim() || undefined,
    idExpiration: d.idExpiration || undefined,
    address: d.address?.trim() || undefined,
    preferredLanguage: d.preferredLanguage ?? "fr",
    notes: d.notes || undefined,
  };
}

export interface DuplicateMatch {
  id: Id<"customers">;
  code: string;
  fullName: string;
  phone: string;
  idNumber: string | null;
  email: string | null;
  reason: string;
  confidence: number;
  fields: ("idNumber" | "phone" | "email" | "name")[];
  reservations: number;
}

export async function findDuplicatesFor(ctx: QueryCtx | MutationCtx, input: { phone?: string | null; idNumber?: string | null; email?: string | null; firstName?: string | null; lastName?: string | null; excludeId?: Id<"customers"> | null }): Promise<DuplicateMatch[]> {
  const phone = input.phone ? normalizePhone(input.phone) : "";
  const pk = phoneKeyOf(phone);
  const idNumber = input.idNumber?.trim() ?? "";
  const email = input.email?.trim().toLowerCase() ?? "";
  const name = input.firstName && input.lastName ? normName(`${input.firstName} ${input.lastName}`) : "";
  const found = new Map<string, Doc<"customers">>();
  const add = (rows: Doc<"customers">[]) => rows.forEach((c) => !c.deletedAt && !c.mergedIntoId && c._id !== input.excludeId && found.set(c._id, c));
  if (pk.length === 8) add(await ctx.db.query("customers").withIndex("by_phoneKey", (q) => q.eq("phoneKey", pk)).take(10));
  if (idNumber.length >= 4) add(await ctx.db.query("customers").withIndex("by_idNumber", (q) => q.eq("idNumber", idNumber)).take(10));
  if (email.includes("@")) add(await ctx.db.query("customers").withIndex("by_email", (q) => q.eq("email", email)).take(10));
  if (name) add(await ctx.db.query("customers").withIndex("by_nameKey", (q) => q.eq("nameKey", name)).take(10));
  const out: DuplicateMatch[] = [];
  for (const c of found.values()) {
    const fields: DuplicateMatch["fields"] = [];
    let score = 0;
    if (idNumber && c.idNumber && c.idNumber.toUpperCase() === idNumber.toUpperCase()) (fields.push("idNumber"), (score += 70));
    if (pk && c.phoneKey === pk) (fields.push("phone"), (score += 50));
    if (email && c.email === email) (fields.push("email"), (score += 40));
    if (name && c.nameKey === name) (fields.push("name"), (score += 25));
    if (!fields.length) continue;
    const reservations = (await ctx.db.query("reservations").withIndex("by_customer_checkIn", (q) => q.eq("customerId", c._id)).take(200)).length;
    out.push({ id: c._id, code: c.code, fullName: c.fullName, phone: c.phone, idNumber: c.idNumber ?? null, email: c.email ?? null, reason: fields.includes("idNumber") ? "Same ID number" : fields.includes("phone") ? "Same phone number" : fields.includes("email") ? "Same email" : "Same name", confidence: Math.min(99, score), fields, reservations });
  }
  return out.sort((a, b) => b.confidence - a.confidence).slice(0, 5);
}

export const findDuplicates = query({
  args: { phone: opt(v.string()), idNumber: opt(v.string()), email: opt(v.string()), firstName: opt(v.string()), lastName: opt(v.string()), excludeId: opt(v.id("customers")) },
  returns: v.array(v.any()),
  handler: async (ctx, input) => {
    await assertPermission(ctx, "customers.view", "customers.create");
    return findDuplicatesFor(ctx, input);
  },
});

export const create = mutation({
  args: { ...customerArgs, ignoreDuplicates: v.optional(v.boolean()) },
  returns: v.object({ id: v.id("customers"), code: v.string() }),
  handler: async (ctx, { ignoreDuplicates, ...data }) => {
    const user = await assertPermission(ctx, "customers.create");
    validate(data);
    // A new profile must never become a side door around a blocked guest.
    const screen = await screenGuest(ctx, { phone: data.phone, idNumber: data.idNumber, email: data.email });
    const gate = screen.matches.find((m) => m.riskLevel === "BLOCKED") ?? screen.matches.find((m) => m.riskLevel === "RESTRICTED");
    if (gate && !can(user, "customers.approve_risky")) throw new AppError(`This ${gate.matchedBy.includes("idNumber") ? "ID number" : "phone number"} belongs to ${gate.fullName} (${gate.code}), who is ${RISK_LEVEL_META[gate.riskLevel as RiskLevel]?.label.toLowerCase() ?? gate.riskLevel}${gate.riskReason ? `: ${gate.riskReason.replace(/\.$/, "")}` : ""}. A new profile cannot be created — use the existing one.`, "PERMISSION", { fields: { __gate: JSON.stringify({ id: gate.id, code: gate.code, fullName: gate.fullName, riskLevel: gate.riskLevel }) } });
    if (!ignoreDuplicates) {
      const dups = await findDuplicatesFor(ctx, data);
      if (dups.length) throw new AppError("Possible existing customer found", "VALIDATION", { fields: { __duplicates: JSON.stringify(dups) } });
    }
    const code = await nextCode(ctx, "customer");
    const id = await ctx.db.insert("customers", { code, ...normalise(data), isBlacklisted: false, riskLevel: "NORMAL", verificationStatus: "UNVERIFIED", updatedAt: Date.now() });
    const row = (await ctx.db.get(id))!;
    await audit(ctx, actorLite(user), { action: "CUSTOMER_CREATED", module: "customers", entityType: "customer", entityId: id, entityLabel: row.fullName, newValue: { phone: row.phone, idNumber: row.idNumber ?? null }, customerId: id });
    return { id, code };
  },
});

export const update = mutation({
  args: { id: v.id("customers"), ...customerArgs },
  returns: v.null(),
  handler: async (ctx, { id, ...data }) => {
    const user = await assertPermission(ctx, "customers.edit");
    validate(data);
    const existing = await ctx.db.get(id);
    if (!existing || existing.deletedAt) throw new AppError("Customer not found", "NOT_FOUND");
    const next = normalise(data);
    const prev: Record<string, unknown> = {};
    const changed: Record<string, unknown> = {};
    for (const k of Object.keys(next) as (keyof typeof next)[]) {
      const a = existing[k] ?? null;
      const b = next[k] ?? null;
      if (a !== b) {
        prev[k] = a;
        changed[k] = b;
      }
    }
    await ctx.db.patch(id, { ...next, updatedAt: Date.now() });
    if (Object.keys(changed).length) await audit(ctx, actorLite(user), { action: "CUSTOMER_EDITED", module: "customers", entityType: "customer", entityId: id, entityLabel: next.fullName, previousValue: prev, newValue: changed, customerId: id });
    return null;
  },
});

export const addNote = mutation({
  args: { customerId: v.id("customers"), body: v.string() },
  returns: v.null(),
  handler: async (ctx, { customerId, body }) => {
    const user = await assertPermission(ctx, "customers.edit", "customers.view");
    const text = body.trim();
    if (text.length < 2) throw new AppError("Note is too short", "VALIDATION");
    const c = await ctx.db.get(customerId);
    if (!c) throw new AppError("Customer not found", "NOT_FOUND");
    await ctx.db.insert("customerNotes", { customerId, authorId: user.id, body: text, at: Date.now() });
    await ctx.db.patch(customerId, { updatedAt: Date.now() });
    await audit(ctx, actorLite(user), { action: "CUSTOMER_NOTE_ADDED", module: "customers", entityType: "customer", entityId: customerId, entityLabel: c.fullName, newValue: text.slice(0, 200), customerId });
    return null;
  },
});

/** Pick one of the 100 guest avatars, or clear it back to the derived one. */
export const setAvatar = mutation({
  args: { customerId: v.id("customers"), avatarIndex: v.union(v.number(), v.null()) },
  returns: v.null(),
  handler: async (ctx, { customerId, avatarIndex }) => {
    const user = await assertPermission(ctx, "customers.edit");
    const c = await ctx.db.get(customerId);
    if (!c || c.deletedAt) throw new AppError("Customer not found", "NOT_FOUND");
    if (avatarIndex !== null && (!Number.isInteger(avatarIndex) || avatarIndex < 0 || avatarIndex > 99)) throw new AppError("Invalid avatar", "VALIDATION");
    await ctx.db.patch(customerId, { avatarIndex: avatarIndex ?? undefined, updatedAt: Date.now() });
    await audit(ctx, actorLite(user), { action: "CUSTOMER_EDITED", module: "customers", entityType: "customer", entityId: customerId, entityLabel: c.fullName, previousValue: { avatarIndex: c.avatarIndex ?? null }, newValue: { avatarIndex }, customerId });
    return null;
  },
});

export const toggleBlacklist = mutation({
  args: { customerId: v.id("customers"), value: v.boolean(), reason: v.string() },
  returns: v.null(),
  handler: async (ctx, { customerId, value, reason }) => {
    const user = await assertPermission(ctx, "customers.edit");
    const c = await ctx.db.get(customerId);
    if (!c) throw new AppError("Customer not found", "NOT_FOUND");
    await ctx.db.patch(customerId, { isBlacklisted: value, updatedAt: Date.now() });
    await audit(ctx, actorLite(user), { action: "CUSTOMER_EDITED", module: "customers", entityType: "customer", entityId: customerId, entityLabel: c.fullName, previousValue: { isBlacklisted: c.isBlacklisted }, newValue: { isBlacklisted: value }, reason, customerId });
    return null;
  },
});

export const remove = mutation({
  args: { id: v.id("customers"), reason: v.string() },
  returns: v.null(),
  handler: async (ctx, { id, reason }) => {
    const user = await assertPermission(ctx, "customers.delete");
    const c = await ctx.db.get(id);
    if (!c) throw new AppError("Customer not found", "NOT_FOUND");
    const count = (await ctx.db.query("reservations").withIndex("by_customer_checkIn", (q) => q.eq("customerId", id)).take(1)).length;
    if (count > 0) throw new AppError("Customers with reservation history cannot be deleted. Blacklist them instead.", "VALIDATION");
    await ctx.db.patch(id, { deletedAt: Date.now(), updatedAt: Date.now() });
    await audit(ctx, actorLite(user), { action: "CUSTOMER_EDITED", module: "customers", entityType: "customer", entityId: id, entityLabel: c.fullName, newValue: "DELETED", reason, customerId: id, severity: "WARNING" });
    return null;
  },
});

// ── Risk, incidents, verification ────────────────────────────
export const setRisk = mutation({
  args: { customerId: v.id("customers"), level: v.string(), reason: v.string() },
  returns: v.null(),
  handler: async (ctx, { customerId, level, reason }) => {
    const user = await assertPermission(ctx, "customers.risk");
    if (!RISK_LEVELS.includes(level as (typeof RISK_LEVELS)[number])) throw new AppError("Invalid classification", "VALIDATION");
    if (level !== "NORMAL" && reason.trim().length < 5) throw new AppError("Give a clear reason for the classification (it is shown to staff).", "VALIDATION");
    const c = await ctx.db.get(customerId);
    if (!c) throw new AppError("Customer not found", "NOT_FOUND");
    await ctx.db.patch(customerId, { riskLevel: level, riskReason: level === "NORMAL" ? undefined : reason.trim(), riskSetAt: Date.now(), riskSetById: user.id, isBlacklisted: level === "BLOCKED", updatedAt: Date.now() });
    await audit(ctx, actorLite(user), { action: "CUSTOMER_RISK_CHANGED", module: "customers", entityType: "customer", entityId: customerId, entityLabel: c.fullName, previousValue: { riskLevel: c.riskLevel, reason: c.riskReason ?? null }, newValue: { riskLevel: level, reason: reason.trim() }, reason: reason.trim(), customerId, severity: level === "BLOCKED" || level === "RESTRICTED" ? "WARNING" : "INFO" });
    return null;
  },
});

export const recordIncident = mutation({
  args: { customerId: v.id("customers"), reservationId: opt(v.id("reservations")), type: v.string(), severity: v.optional(v.string()), title: v.string(), description: opt(v.string()), amount: opt(v.number()), occurredAt: opt(v.string()) },
  returns: v.object({ id: v.id("customerIncidents"), code: v.string() }),
  handler: async (ctx, data) => {
    const user = await assertPermission(ctx, "customers.incidents", "customers.risk");
    if (!INCIDENT_TYPES.includes(data.type as (typeof INCIDENT_TYPES)[number])) throw new AppError("Invalid incident type", "VALIDATION");
    const severity = data.severity ?? "MEDIUM";
    if (!INCIDENT_SEVERITIES.includes(severity as (typeof INCIDENT_SEVERITIES)[number])) throw new AppError("Invalid severity", "VALIDATION");
    if (data.title.trim().length < 3) throw new AppError("Title is required", "VALIDATION", { fields: { title: "Title is required" } });
    const c = await ctx.db.get(data.customerId);
    if (!c) throw new AppError("Customer not found", "NOT_FOUND");
    const code = await nextCode(ctx, "incident");
    const id = await ctx.db.insert("customerIncidents", { code, customerId: data.customerId, reservationId: data.reservationId ?? undefined, type: data.type, severity, title: data.title.trim(), description: data.description || undefined, amount: data.amount ?? undefined, occurredAt: data.occurredAt ? Date.parse(data.occurredAt + "T00:00:00Z") : Date.now(), reportedById: user.id });
    await ctx.db.patch(data.customerId, { updatedAt: Date.now() });
    await audit(ctx, actorLite(user), { action: "CUSTOMER_INCIDENT_RECORDED", module: "customers", entityType: "incident", entityId: id, entityLabel: `${code} · ${data.title}`, newValue: { type: data.type, severity, amount: data.amount ?? null }, customerId: data.customerId, reservationId: data.reservationId ?? null, severity: severity === "HIGH" ? "WARNING" : "INFO" });
    await notify(ctx, { type: "CUSTOMER_INCIDENT", title: "Customer incident recorded", body: `${user.fullName} recorded “${data.title}” for ${c.fullName} (${c.code}).`, priority: severity === "HIGH" ? "HIGH" : "NORMAL", href: `/customers/${c._id}?tab=risk`, entityType: "customer", entityId: c._id, actorId: user.id });
    return { id, code };
  },
});

export const resolveIncident = mutation({
  args: { id: v.id("customerIncidents"), resolution: v.string() },
  returns: v.null(),
  handler: async (ctx, { id, resolution }) => {
    const user = await assertPermission(ctx, "customers.incidents", "customers.risk");
    const inc = await ctx.db.get(id);
    if (!inc) throw new AppError("Incident not found", "NOT_FOUND");
    await ctx.db.patch(id, { resolvedAt: Date.now(), resolution: resolution.trim() || undefined });
    await audit(ctx, actorLite(user), { action: "CUSTOMER_INCIDENT_RESOLVED", module: "customers", entityType: "incident", entityId: id, entityLabel: `${inc.code} · ${inc.title}`, newValue: { resolution: resolution.trim() }, customerId: inc.customerId });
    return null;
  },
});

export const setVerification = mutation({
  args: { customerId: v.id("customers"), status: v.string() },
  returns: v.null(),
  handler: async (ctx, { customerId, status }) => {
    const user = await assertPermission(ctx, "customers.edit", "customers.view_documents");
    if (!VERIFICATION_STATUSES.includes(status as (typeof VERIFICATION_STATUSES)[number])) throw new AppError("Invalid status", "VALIDATION");
    const c = await ctx.db.get(customerId);
    if (!c) throw new AppError("Customer not found", "NOT_FOUND");
    const ids = (await ctx.db.query("documents").withIndex("by_customer", (q) => q.eq("customerId", customerId)).collect()).filter((d) => !d.deletedAt && (d.category === "ID_FRONT" || d.category === "ID_BACK"));
    if (status === "VERIFIED" && ids.length === 0) throw new AppError("Upload the ID document before marking the guest as verified.", "VALIDATION");
    await ctx.db.patch(customerId, { verificationStatus: status, verifiedAt: status === "VERIFIED" ? Date.now() : undefined, updatedAt: Date.now() });
    await audit(ctx, actorLite(user), { action: "CUSTOMER_VERIFIED", module: "customers", entityType: "customer", entityId: customerId, entityLabel: c.fullName, previousValue: c.verificationStatus, newValue: status, customerId });
    return null;
  },
});

export const requestRiskApproval = mutation({
  args: { customerId: v.id("customers"), note: v.string(), dates: v.optional(v.object({ checkIn: v.string(), checkOut: v.string(), apartmentId: v.optional(v.string()) })) },
  returns: v.null(),
  handler: async (ctx, { customerId, note, dates }) => {
    const user = await assertPermission(ctx, "reservations.create");
    const c = await ctx.db.get(customerId);
    if (!c) throw new AppError("Customer not found", "NOT_FOUND");
    const settings = await getSettings(ctx);
    const now = Date.now();
    await ctx.db.insert("tasks", { title: `Approve reservation for ${c.fullName} (${RISK_LEVEL_META[c.riskLevel as keyof typeof RISK_LEVEL_META]?.label ?? c.riskLevel})`, description: `${user.fullName} asks for approval.${dates ? ` Requested ${dates.checkIn} → ${dates.checkOut}.` : ""}${note ? `\n\n${note}` : ""}\n\nRisk reason: ${c.riskReason ?? "—"}`, type: "GENERAL", customerId, priority: "HIGH", createdById: user.id, dueDate: todayKey(settings.timezone), status: "TODO", createdAt: now, updatedAt: now });
    await audit(ctx, actorLite(user), { action: "RISKY_RESERVATION_APPROVAL", module: "customers", entityType: "customer", entityId: customerId, entityLabel: c.fullName, newValue: { note, dates: dates ?? null }, customerId, severity: "WARNING" });
    await notify(ctx, { type: "RISK_APPROVAL_REQUESTED", title: "Approval requested for a restricted guest", body: `${user.fullName} wants to book ${c.fullName} (${c.code}). Reason on file: ${c.riskReason ?? "—"}`, priority: "HIGH", href: `/customers/${c._id}?tab=risk`, entityType: "customer", entityId: c._id, actorId: user.id });
    return null;
  },
});

// ── Merge ─────────────────────────────────────────────────────
export const merge = mutation({
  args: { winnerId: v.id("customers"), loserId: v.id("customers"), reason: v.string() },
  returns: v.object({ moved: v.any() }),
  handler: async (ctx, { winnerId, loserId, reason }) => {
    const user = await assertPermission(ctx, "customers.merge");
    if (winnerId === loserId) throw new AppError("Choose two different profiles.", "VALIDATION");
    if (reason.trim().length < 3) throw new AppError("A reason is required for the audit trail.", "VALIDATION");
    const [winner, loser] = await Promise.all([ctx.db.get(winnerId), ctx.db.get(loserId)]);
    if (!winner || !loser || winner.deletedAt || loser.deletedAt) throw new AppError("Customer not found", "NOT_FOUND");
    const move = async <T extends "reservations" | "contracts" | "payments" | "documents" | "customerNotes" | "tasks" | "customerIncidents" | "auditLog">(table: T, index: string) => {
      const rows = await (ctx.db.query(table) as any).withIndex(index, (q: any) => q.eq("customerId", loserId)).collect();
      for (const row of rows) await ctx.db.patch(row._id, { customerId: winnerId } as any);
      return rows.length as number;
    };
    const counts = {
      reservations: await move("reservations", "by_customer_checkIn"),
      contracts: await move("contracts", "by_customer"),
      payments: await move("payments", "by_customer"),
      documents: await move("documents", "by_customer"),
      notes: await move("customerNotes", "by_customer"),
      tasks: await move("tasks", "by_customer"),
      incidents: await move("customerIncidents", "by_customer"),
      audit: await move("auditLog", "by_customer"),
    };
    const fill: Record<string, unknown> = {};
    for (const k of ["email", "secondaryPhone", "nationality", "dateOfBirth", "idType", "idNumber", "idExpiration", "address"] as const) if (!winner[k] && loser[k]) fill[k] = loser[k];
    const worst = RISK_LEVELS.indexOf(loser.riskLevel as (typeof RISK_LEVELS)[number]) > RISK_LEVELS.indexOf(winner.riskLevel as (typeof RISK_LEVELS)[number]) ? { riskLevel: loser.riskLevel, riskReason: loser.riskReason, riskSetAt: loser.riskSetAt, riskSetById: loser.riskSetById, isBlacklisted: loser.isBlacklisted } : {};
    await ctx.db.patch(winnerId, { ...fill, ...worst, notes: [winner.notes, loser.notes ? `Merged from ${loser.code}: ${loser.notes}` : null].filter(Boolean).join("\n") || undefined, updatedAt: Date.now() });
    await ctx.db.patch(loserId, { mergedIntoId: winnerId, deletedAt: Date.now(), updatedAt: Date.now() });
    await audit(ctx, actorLite(user), { action: "CUSTOMER_MERGED", module: "customers", entityType: "customer", entityId: winnerId, entityLabel: `${loser.code} → ${winner.code}`, previousValue: { loser: { id: loser._id, code: loser.code, fullName: loser.fullName, phone: loser.phone, idNumber: loser.idNumber ?? null, email: loser.email ?? null } }, newValue: { winner: winner.code, moved: counts }, reason: reason.trim(), customerId: winnerId, severity: "WARNING" });
    return { moved: counts };
  },
});

// ── Queries ──────────────────────────────────────────────────
export const get = query({
  args: { id: v.id("customers") },
  returns: v.union(v.null(), v.any()),
  handler: async (ctx, { id }) => {
    await assertPermission(ctx, "customers.view");
    const c = await ctx.db.get(id);
    return c && !c.deletedAt ? withId(c) : null;
  },
});

/** Customers directory rows (list page + segments). */
export const list = query({
  args: {},
  returns: v.object({ rows: v.array(v.any()), stats: v.object({ total: v.number(), new30: v.number(), returning: v.number() }) }),
  handler: async (ctx) => {
    await assertPermission(ctx, "customers.view");
    const all = (await ctx.db.query("customers").collect()).filter((c) => !c.deletedAt);
    const since = Date.now() - 30 * 86_400_000;
    let returning = 0;
    const rows = await Promise.all(
      all
        .filter((c) => !c.mergedIntoId)
        .sort((a, b) => b.updatedAt - a.updatedAt)
        .map(async (c) => {
          const rs = await ctx.db.query("reservations").withIndex("by_customer_checkIn", (q) => q.eq("customerId", c._id)).order("desc").take(100);
          const docs = (await ctx.db.query("documents").withIndex("by_customer", (q) => q.eq("customerId", c._id)).collect()).filter((d) => !d.deletedAt).length;
          if (rs.length > 1) returning++;
          return { id: c._id, code: c.code, fullName: c.fullName, phone: c.phone, email: c.email ?? null, nationality: c.nationality ?? null, idType: c.idType ?? null, idNumber: c.idNumber ?? null, isBlacklisted: c.isBlacklisted, riskLevel: c.riskLevel, verificationStatus: c.verificationStatus, avatarIndex: c.avatarIndex ?? null, reservations: rs.length, documents: docs, lastStay: rs[0]?.checkIn ?? null, totalSpent: rs.filter((r) => isRevenue(r.status)).reduce((s, r) => s + r.totalAmount, 0), createdAt: c._creationTime };
        })
    );
    return { rows, stats: { total: all.length, new30: all.filter((c) => c._creationTime >= since).length, returning } };
  },
});

/** Reservation wizard / quick pick: free-text customer search. */
export const search = query({
  args: { q: v.string(), limit: v.optional(v.number()) },
  returns: v.array(v.any()),
  handler: async (ctx, { q, limit }) => {
    await assertPermission(ctx, "customers.view", "reservations.create");
    const term = q.trim();
    if (term.length < 2) return [];
    // "06 23 89 61 63" must find "+212623896163": compare without the trunk zero / country code.
    const raw = term.replace(/[^\d]/g, "");
    const digits = raw.length >= 9 && raw.startsWith("0") ? raw.slice(1) : raw.startsWith("212") && raw.length >= 12 ? raw.slice(3) : raw;
    const seen = new Map<string, Doc<"customers">>();
    const add = (rows: Doc<"customers">[]) => rows.forEach((c) => !c.deletedAt && !c.mergedIntoId && seen.set(c._id, c));
    add(await ctx.db.query("customers").withSearchIndex("search", (s) => s.search("fullName", term)).take(limit ?? 8));
    const upper = term.toUpperCase();
    const isCode = /^[A-Za-z]{2,3}[- ]?\d/.test(term);
    // Indexed lookups first. A whole phone number, customer code or email is
    // what the desk actually types, and each keystroke used to scan the table.
    let exact = false;
    const hit = (rows: Doc<"customers">[]) => {
      if (!rows.length) return;
      add(rows);
      exact = true;
    };
    if (digits.length >= 8) hit(await ctx.db.query("customers").withIndex("by_phoneKey", (q) => q.eq("phoneKey", digits.slice(-8))).take(8));
    if (term.includes("@")) hit(await ctx.db.query("customers").withIndex("by_email", (q) => q.eq("email", term.toLowerCase())).take(4));
    if (isCode) hit(await ctx.db.query("customers").withIndex("by_code", (q) => q.eq("code", upper.replace(/\s/g, ""))).take(4));
    hit(await ctx.db.query("customers").withIndex("by_idNumber", (q) => q.eq("idNumber", term.trim())).take(4));
    // Only a partial term (half a number, a fragment of an ID) still needs the scan.
    if (!exact && (digits.length >= 4 || isCode || term.includes("@"))) add((await ctx.db.query("customers").collect()).filter((c) => (isCode && c.code.toUpperCase().includes(upper)) || (digits.length >= 4 && !isCode && c.phone.replace(/[^\d]/g, "").includes(digits)) || (term.includes("@") && (c.email ?? "").includes(term.toLowerCase())) || (c.idNumber ?? "").toUpperCase().includes(upper)).slice(0, 8));
    const rows = [...seen.values()].slice(0, limit ?? 8);
    return Promise.all(rows.map(async (c) => ({ id: c._id, code: c.code, fullName: c.fullName, phone: c.phone, email: c.email ?? null, idNumber: c.idNumber ?? null, idType: c.idType ?? null, nationality: c.nationality ?? null, isBlacklisted: c.isBlacklisted, riskLevel: c.riskLevel, riskReason: c.riskReason ?? null, verificationStatus: c.verificationStatus, avatarIndex: c.avatarIndex ?? null, reservations: (await ctx.db.query("reservations").withIndex("by_customer_checkIn", (q) => q.eq("customerId", c._id)).take(100)).length })));
  },
});

/** Customer Risk Center payload. */
export const riskCenter = query({
  args: { level: v.optional(v.string()) },
  returns: v.any(),
  handler: async (ctx, { level }) => {
    await assertPermission(ctx, "customers.risk", "customers.incidents");
    const all = (await ctx.db.query("customers").collect()).filter((c) => !c.deletedAt);
    const counts: Record<string, number> = Object.fromEntries(RISK_LEVELS.map((l) => [l, 0]));
    for (const c of all) counts[c.riskLevel] = (counts[c.riskLevel] ?? 0) + 1;
    const flagged = await Promise.all(
      all
        .filter((c) => c.riskLevel !== "NORMAL" && (!level || c.riskLevel === level))
        .sort((a, b) => (b.riskSetAt ?? 0) - (a.riskSetAt ?? 0))
        .map(async (c) => {
          const incidents = (await ctx.db.query("customerIncidents").withIndex("by_customer", (q) => q.eq("customerId", c._id)).order("desc").collect());
          const reservations = (await ctx.db.query("reservations").withIndex("by_customer_checkIn", (q) => q.eq("customerId", c._id)).take(200)).length;
          return { id: c._id, code: c.code, fullName: c.fullName, phone: c.phone, riskLevel: c.riskLevel, riskReason: c.riskReason ?? null, riskSetAt: c.riskSetAt ?? null, reservations, openIncidents: incidents.filter((i) => !i.resolvedAt).length, lastIncident: incidents[0] ? { title: incidents[0].title } : null };
        })
    );
    const open = (await ctx.db.query("customerIncidents").withIndex("by_open", (q) => q.eq("resolvedAt", undefined)).order("desc").take(60));
    const users = loader(ctx, "users");
    const incidents = await Promise.all(open.map(async (i) => { const [c, by, r] = await Promise.all([ctx.db.get(i.customerId), users(i.reportedById), i.reservationId ? ctx.db.get(i.reservationId) : null]); return { ...withId(i), customer: c ? { id: c._id, code: c.code, fullName: c.fullName, riskLevel: c.riskLevel } : null, reportedBy: { fullName: by?.fullName ?? "" }, reservation: r ? { id: r._id, code: r.code } : null }; }));
    const balances = new Map<string, { customer: { id: Id<"customers">; code: string; fullName: string; riskLevel: string; phone: string }; due: number }>();
    for (const status of ["CHECKED_OUT", "CHECKED_IN"]) for (const r of await ctx.db.query("reservations").withIndex("by_status_checkIn", (q) => q.eq("status", status)).take(500)) {
      const due = r.totalAmount - r.amountPaid;
      if (due <= 0.5) continue;
      const c = await ctx.db.get(r.customerId);
      if (!c) continue;
      balances.set(c._id, { customer: { id: c._id, code: c.code, fullName: c.fullName, riskLevel: c.riskLevel, phone: c.phone }, due: (balances.get(c._id)?.due ?? 0) + due });
    }
    const noShows = new Set((await ctx.db.query("reservations").withIndex("by_status_checkIn", (q) => q.eq("status", "NO_SHOW")).take(500)).map((r) => r.customerId)).size;
    return { counts, flagged, incidents, unpaid: [...balances.values()].sort((a, b) => b.due - a.due).slice(0, 12), noShows };
  },
});

/** Customer 360 — everything the business knows about one guest. */
export const profile360 = query({
  args: { id: v.id("customers") },
  returns: v.union(v.null(), v.any()),
  handler: async (ctx, { id }) => {
    const actor = await assertPermission(ctx, "customers.view");
    const settings = await getSettings(ctx);
    const showMoney = can(actor, "financials.view_revenue") || can(actor, "payments.view");
    const currency = settings.currency;
    const c = await ctx.db.get(id);
    if (!c || (c.deletedAt && !c.mergedIntoId)) return null;
    const users = loader(ctx, "users");
    const apts = loader(ctx, "apartments");
    const [resRaw, docsRaw, contractsRaw, paymentsRaw, notesRaw, incidentsRaw, tasksRaw, auditRaw, mergedFrom] = await Promise.all([
      ctx.db.query("reservations").withIndex("by_customer_checkIn", (q) => q.eq("customerId", id)).order("desc").collect(),
      ctx.db.query("documents").withIndex("by_customer", (q) => q.eq("customerId", id)).order("desc").collect(),
      ctx.db.query("contracts").withIndex("by_customer", (q) => q.eq("customerId", id)).order("desc").collect(),
      ctx.db.query("payments").withIndex("by_customer", (q) => q.eq("customerId", id)).order("desc").collect(),
      ctx.db.query("customerNotes").withIndex("by_customer", (q) => q.eq("customerId", id)).order("desc").collect(),
      ctx.db.query("customerIncidents").withIndex("by_customer", (q) => q.eq("customerId", id)).order("desc").collect(),
      ctx.db.query("tasks").withIndex("by_customer", (q) => q.eq("customerId", id)).order("desc").take(30),
      ctx.db.query("auditLog").withIndex("by_customer", (q) => q.eq("customerId", id)).order("desc").take(80),
      (await ctx.db.query("customers").collect()).filter((x) => x.mergedIntoId === id).map((x) => ({ id: x._id, code: x.code, fullName: x.fullName, deletedAt: x.deletedAt ?? null })),
    ]);
    const reservations = await Promise.all(resRaw.map(async (r) => { const [a, by] = await Promise.all([apts(r.apartmentId), users(r.createdById)]); const history = await ctx.db.query("reservationHistory").withIndex("by_reservation_at", (q) => q.eq("reservationId", r._id)).collect(); const hist = await Promise.all(history.map(async (h) => ({ ...withId(h), performedBy: h.performedById ? { fullName: (await users(h.performedById))?.fullName ?? "" } : null, fromApartment: h.fromApartmentId ? { code: (await apts(h.fromApartmentId))?.code ?? "" } : null, toApartment: h.toApartmentId ? { code: (await apts(h.toApartmentId))?.code ?? "" } : null }))); return { ...withId(r), apartment: a ? { id: a._id, code: a.code, name: a.name } : { id: r.apartmentId, code: "?", name: "" }, createdBy: { fullName: by?.fullName ?? "" }, history: hist }; }));
    const documents = await Promise.all(docsRaw.filter((d) => !d.deletedAt).map(async (d) => ({ ...withId(d), uploadedBy: { fullName: (await users(d.uploadedById))?.fullName ?? "" } })));
    const contracts = await Promise.all(contractsRaw.map(async (k) => ({ ...withId(k), reservation: { code: (await ctx.db.get(k.reservationId))?.code ?? "" }, generatedBy: { fullName: (await users(k.generatedById))?.fullName ?? "" } })));
    const payments = await Promise.all(paymentsRaw.map(async (p) => ({ ...withId(p), recordedBy: { fullName: (await users(p.recordedById))?.fullName ?? "" }, reservation: { id: p.reservationId, code: (await ctx.db.get(p.reservationId))?.code ?? "" } })));
    const customerNotes = await Promise.all(notesRaw.map(async (n) => ({ ...withId(n), author: { fullName: (await users(n.authorId))?.fullName ?? "" } })));
    const incidents = await Promise.all(incidentsRaw.map(async (i) => ({ ...withId(i), reportedBy: { fullName: (await users(i.reportedById))?.fullName ?? "" }, reservation: i.reservationId ? { id: i.reservationId, code: (await ctx.db.get(i.reservationId))?.code ?? "" } : null })));
    const tasks = tasksRaw.filter((t) => t.status === "TODO" || t.status === "IN_PROGRESS").slice(0, 10).map(withId);
    const auditRows = auditRaw.map(withId);
    const riskSetBy = c.riskSetById ? await users(c.riskSetById) : null;

    const completed = reservations.filter((r) => isRevenue(r.status));
    const cancelled = reservations.filter((r) => r.status === "CANCELLED");
    const noShows = reservations.filter((r) => r.status === "NO_SHOW");
    const lifetime = completed.reduce((s, r) => s + r.totalAmount, 0);
    const nights = completed.reduce((s, r) => s + r.nights, 0);
    const outstanding = completed.reduce((s, r) => s + Math.max(0, r.totalAmount - r.amountPaid), 0);
    const today = todayKey(settings.timezone);
    const lastStay = completed.find((r) => r.checkIn <= today) ?? null;
    const nextStay = [...reservations].filter((r) => ["CONFIRMED", "PENDING"].includes(r.status) && r.checkIn > today).sort((a, b) => a.checkIn.localeCompare(b.checkIn))[0] ?? null;
    const current = reservations.find((r) => r.status === "CHECKED_IN") ?? null;
    const byApt = new Map<string, { code: string; name: string; id: Id<"apartments">; n: number }>();
    for (const r of completed) byApt.set(r.apartment.id, { ...r.apartment, n: (byApt.get(r.apartment.id)?.n ?? 0) + 1 });
    const preferredApartment = [...byApt.values()].sort((a, b) => b.n - a.n)[0] ?? null;
    const idDocs = documents.filter((d) => d.category === "ID_FRONT" || d.category === "ID_BACK");
    const earlyCheckouts = reservations.filter((r) => r.earlyCheckout).length;
    const apartmentMoves = reservations.reduce((s, r) => s + r.history.filter((h) => h.type === "APARTMENT_CHANGED").length, 0);
    const openIncidents = incidents.filter((i) => !i.resolvedAt);
    let signal = 0;
    const signals: { label: string; weight: number; tone: "warning" | "negative" | "info" }[] = [];
    if (outstanding > 0) (signal += 25, signals.push({ label: `Outstanding balance ${fmtMoney(outstanding, currency)}`, weight: 25, tone: "negative" }));
    if (cancelled.length >= 2) { const w = Math.min(20, cancelled.length * 7); signal += w; signals.push({ label: `${cancelled.length} cancellations`, weight: w, tone: "warning" }); }
    if (noShows.length) (signal += noShows.length * 15, signals.push({ label: `${noShows.length} no-show${noShows.length > 1 ? "s" : ""}`, weight: noShows.length * 15, tone: "negative" }));
    for (const i of openIncidents) { const w = INCIDENT_TYPE_META[i.type as IncidentType]?.weight ?? 1; const pts = w * (i.severity === "HIGH" ? 12 : i.severity === "MEDIUM" ? 7 : 3); signal += pts; signals.push({ label: `${i.code} · ${i.title}`, weight: pts, tone: i.severity === "HIGH" ? "negative" : "warning" }); }
    signal = Math.min(100, signal);

    const iso = (n: number) => new Date(n).toISOString();
    const ev: { id: string; at: string; kind: string; title: string; detail?: string; ref?: string | null; href?: string; actor?: string; tone?: string }[] = [];
    ev.push({ id: `created-${c._id}`, at: iso(c._creationTime), kind: "profile", title: "Customer profile created", ref: c.code, tone: "neutral" });
    for (const r of reservations) {
      ev.push({ id: `res-${r.id}`, at: iso(r.createdAt), kind: "reservation", title: `Reservation created · ${r.apartment.code}`, detail: `${r.nights} night${r.nights > 1 ? "s" : ""} · ${r.source.toLowerCase()}${showMoney ? ` · ${fmtMoney(r.totalAmount, currency)}` : ""}`, ref: r.code, href: `/reservations/${r.id}`, actor: r.createdBy.fullName, tone: "info" });
      if (r.checkedInAt) ev.push({ id: `in-${r.id}`, at: iso(r.checkedInAt), kind: "reservation", title: `Checked in · ${r.apartment.code}`, ref: r.code, href: `/reservations/${r.id}`, tone: "positive" });
      if (r.checkedOutAt) ev.push({ id: `out-${r.id}`, at: iso(r.checkedOutAt), kind: "reservation", title: r.earlyCheckout ? `Checked out early · ${r.releasedNights} night${r.releasedNights > 1 ? "s" : ""} released` : `Checked out · ${r.apartment.code}`, ref: r.code, href: `/reservations/${r.id}`, tone: r.earlyCheckout ? "accent" : "neutral" });
      if (r.cancelledAt) ev.push({ id: `cx-${r.id}`, at: iso(r.cancelledAt), kind: "reservation", title: `Reservation cancelled · ${r.apartment.code}`, detail: r.cancelReason ?? undefined, ref: r.code, href: `/reservations/${r.id}`, tone: "warning" });
      if (r.status === "NO_SHOW") ev.push({ id: `ns-${r.id}`, at: `${r.checkIn}T00:00:00.000Z`, kind: "reservation", title: `No-show · ${r.apartment.code}`, ref: r.code, href: `/reservations/${r.id}`, tone: "negative" });
      for (const h of r.history) {
        if (h.type === "APARTMENT_CHANGED") ev.push({ id: `mv-${h.id}`, at: iso(h.at), kind: "history", title: `Apartment changed ${h.fromApartment?.code ?? "?"} → ${h.toApartment?.code ?? "?"}`, detail: h.reason ?? undefined, ref: r.code, href: `/reservations/${r.id}`, actor: h.performedBy?.fullName, tone: "accent" });
        else if (h.type === "DATES_CHANGED") ev.push({ id: `dt-${h.id}`, at: iso(h.at), kind: "history", title: "Stay dates changed", detail: h.reason ?? undefined, ref: r.code, href: `/reservations/${r.id}`, actor: h.performedBy?.fullName, tone: "info" });
        else if (h.type === "PRICE_CHANGED" && showMoney) { let prev: any = {}; let next: any = {}; try { prev = JSON.parse(h.previousValue ?? "{}"); next = JSON.parse(h.newValue ?? "{}"); } catch {} ev.push({ id: `pr-${h.id}`, at: iso(h.at), kind: "history", title: "Price changed", detail: `${prev?.total ?? "?"} → ${next?.total ?? "?"} ${currency}`, ref: r.code, href: `/reservations/${r.id}`, actor: h.performedBy?.fullName, tone: "warning" }); }
      }
    }
    for (const d of documents) ev.push({ id: `doc-${d.id}`, at: iso(d.at), kind: "document", title: `${d.category.replace(/_/g, " ").toLowerCase().replace(/^./, (x) => x.toUpperCase())} uploaded`, detail: d.fileName, ref: d.code, href: `/customers/${c._id}?tab=documents`, actor: d.uploadedBy.fullName, tone: "neutral" });
    for (const k of contracts) { ev.push({ id: `ctr-${k.id}`, at: iso(k.at), kind: "contract", title: `Contract generated · v${k.version}`, ref: k.code, href: `/contracts/${k.id}`, actor: k.generatedBy.fullName, tone: "neutral" }); if (k.signedAt) ev.push({ id: `sig-${k.id}`, at: iso(k.signedAt), kind: "contract", title: "Contract signed", ref: k.code, href: `/contracts/${k.id}`, tone: "positive" }); }
    if (showMoney) for (const p of payments) ev.push({ id: `pay-${p.id}`, at: iso(p.paidAt), kind: "payment", title: p.reversedAt ? `Payment reversed · ${fmtMoney(p.amount, currency)}` : `${p.type === "REFUND" || p.type === "DEPOSIT_REFUND" ? "Refund" : p.type === "DEPOSIT" ? "Deposit" : "Payment"} · ${fmtMoney(p.amount, currency)}`, detail: `${p.method.toLowerCase()} · ${p.reservation.code}`, ref: p.code, href: `/reservations/${p.reservation.id}?tab=payments`, actor: p.recordedBy.fullName, tone: p.reversedAt ? "warning" : "positive" });
    for (const i of incidents) { ev.push({ id: `inc-${i.id}`, at: iso(i.occurredAt), kind: "incident", title: `Incident · ${i.title}`, detail: `${INCIDENT_TYPE_META[i.type as IncidentType]?.label ?? i.type} · ${i.severity.toLowerCase()} severity${i.amount ? ` · ${fmtMoney(i.amount, currency)}` : ""}`, ref: i.code, href: `/customers/${c._id}?tab=risk`, actor: i.reportedBy.fullName, tone: i.severity === "HIGH" ? "negative" : "warning" }); if (i.resolvedAt) ev.push({ id: `incr-${i.id}`, at: iso(i.resolvedAt), kind: "incident", title: `Incident resolved · ${i.title}`, detail: i.resolution ?? undefined, ref: i.code, href: `/customers/${c._id}?tab=risk`, tone: "positive" }); }
    for (const n of customerNotes) ev.push({ id: `note-${n.id}`, at: iso(n.at), kind: "note", title: "Staff note", detail: n.body, actor: n.author.fullName, href: `/customers/${c._id}?tab=notes`, tone: "neutral" });
    for (const a of auditRows) {
      if (a.action === "CUSTOMER_RISK_CHANGED") { let next: any = {}; try { next = JSON.parse(a.newValue ?? "{}"); } catch {} ev.push({ id: `aud-${a.id}`, at: iso(a.at), kind: "risk", title: `Classification set to ${String(next?.riskLevel ?? "").replace(/_/g, " ").toLowerCase()}`, detail: a.reason ?? undefined, ref: a.code, actor: a.userName, tone: "warning" }); }
      else if (a.action === "CUSTOMER_EDITED" || a.action === "CUSTOMER_VERIFIED" || a.action === "CUSTOMER_MERGED") { let nv: any = ""; try { nv = JSON.parse(a.newValue ?? '""'); } catch {} ev.push({ id: `aud-${a.id}`, at: iso(a.at), kind: "audit", title: a.action === "CUSTOMER_MERGED" ? `Profiles merged · ${a.entityLabel}` : a.action === "CUSTOMER_VERIFIED" ? `Verification: ${String(nv).toLowerCase()}` : "Profile edited", detail: a.reason ?? undefined, ref: a.code, actor: a.userName, tone: "neutral" }); }
    }
    ev.sort((a, b) => b.at.localeCompare(a.at));

    const connected = [
      { key: "reservations", label: "Reservations", icon: "BookOpenCheck", total: reservations.length, moreHref: `/customers/${c._id}?tab=reservations`, items: reservations.slice(0, 5).map((r) => ({ ref: r.code, label: `${r.apartment.code} · ${r.apartment.name}`, sub: `${r.checkIn} → ${r.checkOut} · ${r.status.toLowerCase().replace("_", " ")}`, href: `/reservations/${r.id}` })) },
      { key: "apartments", label: "Apartments stayed in", icon: "Building2", items: [...byApt.values()].map((a) => ({ ref: a.code, label: a.name, sub: `${a.n} stay${a.n > 1 ? "s" : ""}`, href: `/apartments/${a.id}` })) },
      { key: "contracts", label: "Contracts", icon: "FileText", total: contracts.length, moreHref: `/customers/${c._id}?tab=contracts`, items: contracts.slice(0, 4).map((k) => ({ ref: k.code, label: `${k.reservation.code} · v${k.version}`, sub: k.status.toLowerCase(), href: `/contracts/${k.id}` })) },
      ...(showMoney ? [{ key: "payments", label: "Payments", icon: "Wallet", total: payments.length, moreHref: `/customers/${c._id}?tab=payments`, items: payments.slice(0, 4).map((p) => ({ ref: p.code, label: fmtMoney(p.amount, currency), sub: `${p.reservation.code} · ${p.method.toLowerCase()}`, href: `/reservations/${p.reservation.id}?tab=payments` })) }] : []),
      { key: "documents", label: "Documents", icon: "FolderLock", total: documents.length, moreHref: `/customers/${c._id}?tab=documents`, items: documents.slice(0, 4).map((d) => ({ ref: d.code, label: d.fileName, sub: d.category.replace(/_/g, " ").toLowerCase(), href: `/customers/${c._id}?tab=documents` })) },
      { key: "incidents", label: "Incidents", icon: "ShieldAlert", total: incidents.length, moreHref: `/customers/${c._id}?tab=risk`, items: incidents.slice(0, 4).map((i) => ({ ref: i.code, label: i.title, sub: `${i.severity.toLowerCase()} · ${i.resolvedAt ? "resolved" : "open"}`, href: `/customers/${c._id}?tab=risk` })) },
      { key: "workers", label: "Workers involved", icon: "UserCog", items: [...new Set(reservations.map((r) => r.createdBy.fullName))].slice(0, 5).map((n) => ({ label: n, sub: "created reservations", href: `/workers?q=${encodeURIComponent(n)}` })) },
      { key: "audit", label: "Audit events", icon: "ScrollText", total: auditRows.length, moreHref: `/audit?customer=${c._id}`, items: auditRows.slice(0, 3).map((a) => ({ ref: a.code, label: a.action.replace(/_/g, " ").toLowerCase(), sub: `${a.userName} · ${new Date(a.at).toISOString().slice(0, 10)}`, href: `/audit?event=${a.id}` })) },
    ];
    const duplicates = await findDuplicatesFor(ctx, { phone: c.phone, idNumber: c.idNumber, email: c.email, firstName: c.firstName, lastName: c.lastName, excludeId: c._id });
    return {
      c: { ...withId(c), documents, reservations, contracts, payments, customerNotes, incidents, tasks },
      stats: { completed: completed.length, cancelled: cancelled.length, noShows: noShows.length, lifetime, nights, outstanding, lastStay, nextStay, current, preferredApartment, idDocs: idDocs.length, earlyCheckouts, apartmentMoves, openIncidents: openIncidents.length },
      risk: { signal, signals, setBy: riskSetBy?.fullName ?? null },
      timeline: ev,
      connected,
      mergedFrom,
      audit: auditRows,
      duplicates,
    };
  },
});

/** Wizard preset: one customer with the stay count. */
export const lite = query({
  args: { id: v.id("customers") },
  returns: v.union(v.null(), v.any()),
  handler: async (ctx, { id }) => {
    await assertPermission(ctx, "customers.view", "reservations.create");
    const c = await ctx.db.get(id);
    if (!c || c.deletedAt) return null;
    const n = (await ctx.db.query("reservations").withIndex("by_customer_checkIn", (q) => q.eq("customerId", id)).take(500)).length;
    return { id: c._id, code: c.code, fullName: c.fullName, phone: c.phone, idNumber: c.idNumber ?? null, idType: c.idType ?? null, riskLevel: c.riskLevel, riskReason: c.riskReason ?? null, verificationStatus: c.verificationStatus, email: c.email ?? null, nationality: c.nationality ?? null, isBlacklisted: c.isBlacklisted, avatarIndex: c.avatarIndex ?? null, _count: { reservations: n } };
  },
});
