"use client";

// Leads data layer. Serves EITHER the local mock (when the Shell isn't configured — the
// polished demo keeps working) OR the live be-crm data door (crm.lead.*), behind one
// interface so the Leads board is mode-agnostic. Mirrors team-api.ts exactly.
//
// Live contract (be-crm):
//   query crm.lead.search { query?, status?, priority?, source?, assigneeId?, page?, perPage? }
//                                                    -> { items: LeadCardDTO[], meta }
//   query crm.lead.get    { id }                     -> LeadDTO (full detail)
//   query crm.lead.stats  {}                         -> { total, byStatus }
//   cmd   crm.lead.create <payload>                  -> LeadDTO
//   cmd   crm.lead.updateStatus { id, status, note? } -> LeadDTO
//   cmd   crm.lead.assign { id, assigneeId | null }  -> LeadDTO

import { useCallback, useMemo, useState } from "react";
import { useAppShell, useQuery } from "@abe-kap/appshell-sdk/react";
import { isShellConfigured } from "./appshell";
import { useUploadAttachment, useDownloadUrl, type UploadedAttachment } from "./media-api";
import {
  LEADS as seedLeads, TOTAL_LEADS, REPS as seedReps,
  type Lead, type LeadStatus, type LeadPriority, type Phone, type Email, type Rep, type Attachment,
} from "@/components/dashboard/leads-data";

/* ---- create payload (union of Quick + Full form; §6.3) ------------------ */

export interface CreateLeadInput {
  firstName: string;
  lastName?: string;
  phones: { number: string; type: "Mobile" | "Home" | "Work"; primary?: boolean }[];
  emails?: { address: string; primary?: boolean }[];
  property?: { address?: string; city?: string; state?: string; zip?: string; type?: string };
  photos?: { contentRef: string; mimeType: string; sizeBytes: number; filename: string }[];
  job?: {
    source?: string; referralNote?: string; canvasserId?: string;
    leadType?: string; workType?: string; tradeType?: string;
    urgency?: "Standard" | "High" | "Emergency"; notes?: string;
  };
  insurance?: {
    company?: string; claimNumber?: string; claimStatus?: string;
    adjusterName?: string; adjusterPhone?: string; policyNumber?: string;
  };
  assignment?: { assigneeId?: string | null; priority?: "Low" | "Medium" | "High"; followUp?: string };
}

/** Partial update patch — flat camelCase fields (matches the tested `crm.lead.update` #21). */
export interface LeadPatch {
  firstName?: string; lastName?: string;
  priority?: string;
  propertyAddress?: string; propertyCity?: string; propertyState?: string; propertyZip?: string; propertyType?: string;
  source?: string; leadType?: string; workType?: string; tradeType?: string; urgency?: string; jobNotes?: string;
  insuranceCompany?: string; insuranceClaimStatus?: string; insuranceClaimNumber?: string;
  insurancePolicyNumber?: string; insuranceAdjusterName?: string; insuranceAdjusterPhone?: string;
  assignedToId?: string | null; followUpDate?: string;
  phones?: { number: string; type: string; primary?: boolean }[];
  emails?: { address: string; primary?: boolean }[];
}

export interface LeadsData {
  live: boolean; loading: boolean; error: string | null;
  leads: Lead[];
  total: number;
  byStatus: Record<LeadStatus, number>;
  /** Rep / canvasser / assignee picker options. In live mode `id` is the member's principalId
   *  (the value be-crm resolves assignee/canvasser references against), not the mock REP code. */
  reps: Rep[];
  /** Hydrate a single lead's full detail (list rows carry card-level fields only in live mode). */
  getLead: (lead: Lead) => Promise<Lead>;
  createLead: (input: CreateLeadInput) => Promise<void>;
  /** Upload a site photo → {contentRef,…}. Feed the results into `createLead`'s `photos`. */
  uploadPhoto: (file: File) => Promise<UploadedAttachment>;
  /** Mint a short-lived signed URL to render a stored photo by its contentRef. */
  getPhotoUrl: (contentRef: string, mime?: string) => Promise<string>;
  /** Attach an already-uploaded photo to an existing lead (Edit mode) — crm.lead.attachment.add. */
  addPhoto: (lead: Lead, attachment: UploadedAttachment) => Promise<void>;
  /** Detach a stored photo from a lead (Edit mode) — crm.lead.attachment.remove. */
  removePhoto: (lead: Lead, attachment: Attachment) => Promise<void>;
  /** Edit an existing lead (Edit button on the detail popup). */
  updateLead: (lead: Lead, patch: LeadPatch) => Promise<void>;
  updateStatus: (lead: Lead, status: LeadStatus, note?: string) => Promise<void>;
  assign: (lead: Lead, assigneeId: string | null) => Promise<void>;
  /** Push the lead into the Lead Verification queue (crm.lead.sendForVerification). Idempotent server-side. */
  sendForVerification: (lead: Lead) => Promise<void>;
  refetch: () => void;
}

/* ---- helpers ------------------------------------------------------------ */

const GRADIENTS = [
  "linear-gradient(135deg,#fda913,#fd6d13)", "linear-gradient(135deg,#4f8cff,#2c5cff)",
  "linear-gradient(135deg,#b07bf2,#7b53e0)", "linear-gradient(135deg,#33c98a,#1fa46c)",
  "linear-gradient(135deg,#34c9d6,#1f9aa4)",
];
const gradientFor = (id: string) => GRADIENTS[[...id].reduce((a, c) => a + c.charCodeAt(0), 0) % GRADIENTS.length];
const initialsOf = (name: string) =>
  name.split(/\s+/).filter(Boolean).slice(0, 2).map((p) => p[0]).join("").toUpperCase() || "?";
const firstNameOf = (name: string) => name.split(/\s+/)[0] ?? "";

const relTime = (iso?: string | null): string => {
  if (!iso) return "—";
  const then = Date.parse(iso);
  if (Number.isNaN(then)) return iso;
  const diff = Date.now() - then;
  const day = 86_400_000;
  if (diff >= 0 && diff < 7 * day) {
    const d = Math.floor(diff / day);
    if (d <= 0) return "today";
    return `${d}d ago`;
  }
  return new Date(then).toLocaleDateString(undefined, { day: "numeric", month: "short", year: "numeric" });
};
const prettyDate = (iso?: string | null): string => {
  if (!iso) return "—";
  const then = Date.parse(iso);
  if (Number.isNaN(then)) return iso;
  return new Date(then).toLocaleDateString(undefined, { day: "numeric", month: "short", year: "numeric" });
};
const lc = (p?: string | null): LeadPriority =>
  (String(p ?? "medium").toLowerCase() as LeadPriority);

const EMPTY_BY_STATUS: Record<LeadStatus, number> = { new: 0, contacted: 0, appointed: 0, closed: 0 };

/* ---- be-crm DTO types (defensive: backend is greenfield) ---------------- */

interface LeadCardDTO {
  id: string; code: string; name: string; initials?: string; gradient?: string;
  priority?: string; status?: LeadStatus; tag?: string;
  primaryPhone?: string; propertyAddress?: string; propertyCity?: string; propertyState?: string;
  source?: string; canvasserName?: string; updatedAt?: string;
  // Set once the lead has an associated verification record (blocks a duplicate send).
  verificationId?: string | null; verificationStatus?: string | null;
}
interface LeadDTO extends LeadCardDTO {
  phones?: Phone[]; emails?: Email[];
  propertyZip?: string; propertyType?: string;
  leadType?: string; workType?: string; tradeType?: string; urgency?: string; jobNotes?: string;
  referralNote?: string; canvasserId?: string;
  insuranceCompany?: string; insuranceClaimStatus?: string; insuranceClaimNumber?: string;
  insurancePolicyNumber?: string; insuranceAdjusterName?: string; insuranceAdjusterPhone?: string;
  assignedToName?: string; createdByName?: string; followUpDate?: string; createdAt?: string;
  assignedToId?: string | null; createdById?: string | null;
  stormZone?: string; stormDate?: string; stormDetail?: string;
  // assignee / creator can come back under several field names depending on the DTO projection.
  // The object form carries an `id` (be-crm currently echoes the principalId as `name`, unresolved).
  assignedTo?: string | { id?: string; name?: string; displayName?: string } | null;
  assignee?: string | { id?: string; name?: string; displayName?: string } | null;
  createdBy?: string | { id?: string; name?: string; displayName?: string } | null;
  creatorName?: string;
  // site photos — LeadDTO detail projection carries attachments[] (§6.2)
  attachments?: Attachment[];
  // tolerate an already-grouped detail shape too
  property?: Lead["property"]; job?: Partial<Lead["job"]>;
  insurance?: Partial<Lead["insurance"]>; assignment?: Partial<Lead["assignment"]>;
  storm?: Partial<Lead["storm"]>;
}

/** Pull a display name out of whatever shape the backend returns the assignee in. */
function nameOf(v: string | { name?: string; displayName?: string } | null | undefined): string {
  if (!v) return "";
  if (typeof v === "string") return v;
  return v.displayName?.trim() || v.name?.trim() || "";
}

/** Pull the member id out of an object-shaped member reference (string refs carry no id). */
function idOf(v: string | { id?: string } | null | undefined): string {
  if (!v || typeof v === "string") return "";
  return v.id?.trim() ?? "";
}

/** Resolve a member reference to a display name. be-crm currently echoes the raw principalId
 *  as the `name`, so prefer the local reps list (principalId → real name); fall back to a
 *  backend name only when it isn't just the id, then to the id itself. */
function resolveMemberName(id: string, backendName: string, reps: Rep[]): string {
  const rep = id ? reps.find((r) => r.id === id) : undefined;
  if (rep) return rep.name;
  if (backendName && backendName !== id) return backendName;
  return backendName || id;
}

/* ---- DTO → UI Lead ------------------------------------------------------ */

function cardToLead(d: LeadCardDTO): Lead {
  const name = d.name ?? "";
  return {
    id: d.code || d.id,
    refId: d.id,
    verificationId: d.verificationId ?? (d.verificationStatus ? String(d.verificationStatus) : undefined) ?? undefined,
    initials: d.initials || initialsOf(name),
    name,
    gradient: d.gradient || gradientFor(d.id),
    priority: lc(d.priority),
    status: (d.status ?? "new") as LeadStatus,
    tag: d.tag ?? "Storm Zone",
    updated: relTime(d.updatedAt),
    setter: firstNameOf(d.canvasserName ?? ""),
    storm: { zone: "", date: "", detail: "" },
    phones: d.primaryPhone ? [{ number: d.primaryPhone, type: "Mobile", primary: true }] : [],
    emails: [],
    property: {
      address: d.propertyAddress ?? "", city: d.propertyCity ?? "",
      state: d.propertyState ?? "", zip: "", type: "",
    },
    job: {
      source: d.source ?? "", leadType: "", workType: "", tradeType: "",
      urgency: "", canvasser: d.canvasserName ?? "", notes: "",
    },
    insurance: { company: "", claimStatus: "", claimNumber: "", policyNumber: "", adjusterName: "", adjusterPhone: "" },
    assignment: { assignedTo: "", priority: "", followUp: "", createdBy: "", createdAt: "" },
  };
}

function detailToLead(d: LeadDTO, fallback: Lead, reps: Rep[]): Lead {
  const base = cardToLead(d);
  const assigneeId = d.assignedToId || idOf(d.assignedTo) || idOf(d.assignee) || "";
  const assigneeName = d.assignedToName || nameOf(d.assignedTo) || nameOf(d.assignee) || "";
  const creatorId = d.createdById || idOf(d.createdBy) || "";
  const creatorName = d.createdByName || d.creatorName || nameOf(d.createdBy) || "";
  return {
    ...base,
    storm: d.storm
      ? { zone: d.storm.zone ?? "", date: d.storm.date ?? "", detail: d.storm.detail ?? "" }
      : { zone: d.stormZone ?? fallback.storm.zone, date: d.stormDate ?? fallback.storm.date, detail: d.stormDetail ?? fallback.storm.detail },
    phones: d.phones?.length ? d.phones : base.phones.length ? base.phones : fallback.phones,
    emails: d.emails ?? [],
    attachments: d.attachments ?? fallback.attachments ?? [],
    property: d.property ?? {
      address: d.propertyAddress ?? "", city: d.propertyCity ?? "", state: d.propertyState ?? "",
      zip: d.propertyZip ?? "", type: d.propertyType ?? "",
    },
    job: {
      source: d.job?.source ?? d.source ?? "",
      leadType: d.job?.leadType ?? d.leadType ?? "",
      workType: d.job?.workType ?? d.workType ?? "",
      tradeType: d.job?.tradeType ?? d.tradeType ?? "",
      urgency: d.job?.urgency ?? d.urgency ?? "",
      canvasser: d.job?.canvasser ?? d.canvasserName ?? "",
      notes: d.job?.notes ?? d.jobNotes ?? "",
    },
    insurance: {
      company: d.insurance?.company ?? d.insuranceCompany ?? "",
      claimStatus: d.insurance?.claimStatus ?? d.insuranceClaimStatus ?? "",
      claimNumber: d.insurance?.claimNumber ?? d.insuranceClaimNumber ?? "",
      policyNumber: d.insurance?.policyNumber ?? d.insurancePolicyNumber ?? "",
      adjusterName: d.insurance?.adjusterName ?? d.insuranceAdjusterName ?? "",
      adjusterPhone: d.insurance?.adjusterPhone ?? d.insuranceAdjusterPhone ?? "",
    },
    assignment: {
      assignedTo: d.assignment?.assignedTo
        || resolveMemberName(assigneeId, assigneeName, reps)
        || "Unassigned",
      priority: d.assignment?.priority ?? (d.priority ?? ""),
      followUp: d.assignment?.followUp ?? prettyDate(d.followUpDate),
      createdBy: d.assignment?.createdBy
        || resolveMemberName(creatorId, creatorName, reps),
      createdAt: d.assignment?.createdAt ?? prettyDate(d.createdAt),
    },
  };
}

/* ======================================================================== */
/* Mock implementation (no Shell configured)                                */
/* ======================================================================== */

function useMockLeads(): LeadsData {
  const [leads, setLeads] = useState<Lead[]>(() => seedLeads);

  const byStatus = useMemo(() => {
    const m = { ...EMPTY_BY_STATUS };
    for (const l of leads) m[l.status] += 1;
    return m;
  }, [leads]);

  const getLead = useCallback(async (lead: Lead) => lead, []);

  const createLead = useCallback(async (input: CreateLeadInput) => {
    const name = `${input.firstName} ${input.lastName ?? ""}`.trim();
    const phones: Phone[] = input.phones
      .filter((p) => p.number.trim())
      .map((p, i) => ({ number: p.number, type: p.type, primary: p.primary ?? i === 0 }));
    const seq = seedLeads.length + leads.length + 1;
    const lead: Lead = {
      id: `SAL-${String(seq).padStart(3, "0")}`,
      initials: initialsOf(name),
      name,
      gradient: gradientFor(name),
      priority: lc(input.assignment?.priority),
      status: "new",
      tag: "Storm Zone",
      updated: "today",
      setter: firstNameOf(input.job?.canvasserId ?? ""),
      storm: { zone: "", date: "", detail: "" },
      phones,
      emails: (input.emails ?? []).filter((e) => e.address.trim()).map((e, i) => ({ address: e.address, primary: e.primary ?? i === 0 })),
      attachments: input.photos ?? [],
      property: {
        address: input.property?.address ?? "", city: input.property?.city ?? "",
        state: input.property?.state ?? "TX", zip: input.property?.zip ?? "", type: input.property?.type ?? "",
      },
      job: {
        source: input.job?.source ?? "", leadType: input.job?.leadType ?? "",
        workType: input.job?.workType ?? "", tradeType: input.job?.tradeType ?? "",
        urgency: input.job?.urgency ?? "Standard", canvasser: input.job?.canvasserId ?? "", notes: input.job?.notes ?? "",
      },
      insurance: {
        company: input.insurance?.company ?? "", claimStatus: input.insurance?.claimStatus ?? "",
        claimNumber: input.insurance?.claimNumber ?? "", policyNumber: input.insurance?.policyNumber ?? "",
        adjusterName: input.insurance?.adjusterName ?? "", adjusterPhone: input.insurance?.adjusterPhone ?? "",
      },
      assignment: {
        assignedTo: input.assignment?.assigneeId ?? "Unassigned",
        priority: input.assignment?.priority ?? "Medium",
        followUp: input.assignment?.followUp || "—",
        createdBy: "You", createdAt: "today",
      },
    };
    setLeads((l) => [lead, ...l]);
  }, [leads.length]);

  const updateLead = useCallback(async (lead: Lead, patch: LeadPatch) => {
    setLeads((l) => l.map((x) => (x.id === lead.id ? applyPatch(x, patch) : x)));
  }, []);
  const updateStatus = useCallback(async (lead: Lead, status: LeadStatus) => {
    setLeads((l) => l.map((x) => (x.id === lead.id ? { ...x, status } : x)));
  }, []);
  const assign = useCallback(async (lead: Lead, assigneeId: string | null) => {
    setLeads((l) => l.map((x) => (x.id === lead.id ? { ...x, assignment: { ...x.assignment, assignedTo: assigneeId ?? "Unassigned" } } : x)));
  }, []);
  // No shared store between the mock leads/verify hooks, so this is a no-op in demo mode
  // (the component still toasts success). Live mode does the real intake.
  const sendForVerification = useCallback(async (_lead: Lead) => {}, []);

  // Demo mode has no storage door — keep the bytes in-browser via an object URL so the
  // uploaded photo still renders in the detail popup. `getPhotoUrl` passes it straight through.
  const uploadPhoto = useCallback(async (file: File): Promise<UploadedAttachment> => ({
    contentRef: URL.createObjectURL(file), mimeType: file.type || "image/jpeg", sizeBytes: file.size, filename: file.name,
  }), []);
  const getPhotoUrl = useCallback(async (contentRef: string) => contentRef, []);
  const addPhoto = useCallback(async (lead: Lead, attachment: UploadedAttachment) => {
    setLeads((l) => l.map((x) => (x.id === lead.id ? { ...x, attachments: [...(x.attachments ?? []), attachment] } : x)));
  }, []);
  const removePhoto = useCallback(async (lead: Lead, attachment: Attachment) => {
    setLeads((l) => l.map((x) => (x.id === lead.id ? { ...x, attachments: (x.attachments ?? []).filter((a) => a.contentRef !== attachment.contentRef) } : x)));
  }, []);

  return {
    live: false, loading: false, error: null,
    leads, total: TOTAL_LEADS, byStatus, reps: seedReps,
    getLead, createLead, updateLead, updateStatus, assign, sendForVerification,
    uploadPhoto, getPhotoUrl, addPhoto, removePhoto, refetch: () => {},
  };
}

/** Apply a flat LeadPatch onto the nested UI Lead (mock mode only). */
function applyPatch(lead: Lead, p: LeadPatch): Lead {
  const name = p.firstName !== undefined || p.lastName !== undefined
    ? `${p.firstName ?? lead.name.split(" ")[0] ?? ""} ${p.lastName ?? lead.name.split(" ").slice(1).join(" ")}`.trim()
    : lead.name;
  return {
    ...lead,
    name,
    initials: name ? initialsOf(name) : lead.initials,
    priority: p.priority ? lc(p.priority) : lead.priority,
    phones: p.phones?.length ? p.phones.map((x, i) => ({ number: x.number, type: x.type as Phone["type"], primary: x.primary ?? i === 0 })) : lead.phones,
    emails: p.emails ? p.emails.map((x, i) => ({ address: x.address, primary: x.primary ?? i === 0 })) : lead.emails,
    property: {
      address: p.propertyAddress ?? lead.property.address,
      city: p.propertyCity ?? lead.property.city,
      state: p.propertyState ?? lead.property.state,
      zip: p.propertyZip ?? lead.property.zip,
      type: p.propertyType ?? lead.property.type,
    },
    job: {
      ...lead.job,
      source: p.source ?? lead.job.source,
      leadType: p.leadType ?? lead.job.leadType,
      workType: p.workType ?? lead.job.workType,
      tradeType: p.tradeType ?? lead.job.tradeType,
      urgency: p.urgency ?? lead.job.urgency,
      notes: p.jobNotes ?? lead.job.notes,
    },
    insurance: {
      company: p.insuranceCompany ?? lead.insurance.company,
      claimStatus: p.insuranceClaimStatus ?? lead.insurance.claimStatus,
      claimNumber: p.insuranceClaimNumber ?? lead.insurance.claimNumber,
      policyNumber: p.insurancePolicyNumber ?? lead.insurance.policyNumber,
      adjusterName: p.insuranceAdjusterName ?? lead.insurance.adjusterName,
      adjusterPhone: p.insuranceAdjusterPhone ?? lead.insurance.adjusterPhone,
    },
    assignment: {
      ...lead.assignment,
      assignedTo: p.assignedToId !== undefined ? (p.assignedToId ?? "Unassigned") : lead.assignment.assignedTo,
      priority: p.priority ?? lead.assignment.priority,
      followUp: p.followUpDate ?? lead.assignment.followUp,
    },
  };
}

/* ======================================================================== */
/* Live implementation (be-crm data door)                                   */
/* ======================================================================== */

interface Page<T> { items: T[]; meta?: { total?: number } }
interface MemberDTO { id: string; principalId?: string; displayName?: string | null; firstName?: string | null; lastName?: string | null; jobTitle?: string | null; email?: string | null }

/* ---- error surfacing ---------------------------------------------------- */
// appshell's DataClient throws an opaque "data command failed: <status>" and DROPS the response
// body, so the backend's real error (e.g. the duplicate_lead guard) never reaches the UI. For
// writes where the exact server message matters, `bffCommand` uses the same BFF transport but
// parses the error body and throws the backend's own message.

const BFF_BASE = (process.env.NEXT_PUBLIC_BFF_BASE_URL ?? "/shell").replace(/\/$/, "");

// Observed be-crm error body (Fastify style):
//   { statusCode, error: "Conflict", message: "duplicate_lead",
//     detail: "A lead with the same primary phone and address already exists (SAL-010).",
//     duplicateOf: [{ id, code, name }] }
// i.e. `message` is a CODE slug, `detail` is the human sentence, `duplicateOf` is an array.
// Also tolerate a nested `{ error: { code, message } }` shape from other services.
type DupRef = { id?: string; code?: string; name?: string };
interface BackendError {
  statusCode?: number; code?: string; error?: string | BackendError;
  message?: string; detail?: string;
  duplicateOf?: DupRef[] | DupRef | null;
  details?: { duplicateOf?: DupRef[] | DupRef | null };
}

function firstDup(body: BackendError): DupRef | null {
  const d = body.duplicateOf ?? body.details?.duplicateOf ?? null;
  if (!d) return null;
  return Array.isArray(d) ? (d[0] ?? null) : d;
}

/** Show the backend's own human sentence verbatim; map a bare code slug otherwise. */
function leadErrorText(code: string, human: string, body: BackendError, status: number): string {
  if (human) return human;                           // e.g. `detail` — exactly what the server said
  switch (code) {                                    // slug with no human sentence
    case "duplicate_lead": {
      const who = firstDup(body)?.name || firstDup(body)?.code;
      return who
        ? `A lead with the same primary phone and address already exists (${who}).`
        : "A lead with the same primary phone and address already exists.";
    }
    case "name_required": return "Enter the homeowner's first or last name.";
    case "invalid_assignee": return "The selected rep isn't a valid team member.";
    case "file_too_large": return "That photo is too large (max 25 MB).";
    default: return `Couldn’t complete the request (${status}).`;
  }
}

async function bffCommand<T>(action: string, variables: Record<string, unknown>): Promise<T> {
  const res = await fetch(`${BFF_BASE}/data/command`, {
    method: "POST",
    credentials: "include",
    headers: {
      "Content-Type": "application/json",
      "X-Idempotency-Key": globalThis.crypto?.randomUUID?.() ?? Math.random().toString(36).slice(2),
    },
    body: JSON.stringify({ action, variables }),
  });
  if (res.status === 401) throw new Error("SESSION_EXPIRED");
  if (res.ok) return (await res.json()) as T;
  let body: BackendError = {};
  try { body = (await res.json()) as BackendError; } catch { /* non-JSON error body */ }
  const inner = (body.error && typeof body.error === "object" ? body.error : body) as BackendError;
  const raw = inner.message ?? "";
  const isSlug = /^[a-z][a-z0-9_]*$/.test(raw);       // "duplicate_lead" is a code, not a sentence
  const code = inner.code ?? (isSlug ? raw : "");
  const human = inner.detail ?? (isSlug ? "" : raw);
  const e = new Error(leadErrorText(code, human, inner, res.status));
  (e as unknown as { code?: string }).code = code;
  throw e;
}

function useLiveLeads(): LeadsData {
  const { sdk } = useAppShell();
  const uploadPhoto = useUploadAttachment();   // presignUpload → PUT bytes → { contentRef, … }
  const getPhotoUrl = useDownloadUrl();         // presignDownload → signed URL
  // useQuery re-runs only on `action` change, so fetch a broad page and let the
  // board filter/search client-side (same shape team-api uses).
  const listQ = useQuery<Page<LeadCardDTO>>("crm.lead.search", { perPage: 100 });
  const statsQ = useQuery<{ total?: number; byStatus?: Partial<Record<LeadStatus, number>> }>("crm.lead.stats", {});
  // Rep/canvasser/assignee picker source (§6.6). be-crm resolves assignee/canvasser refs by
  // principalId, so that — not the opaque member id — is the value the form must send.
  const membersQ = useQuery<{ items: MemberDTO[] }>("crm.team.member.search", { perPage: 100 });

  const reps: Rep[] = useMemo(() => (membersQ.data?.items ?? []).map((m) => {
    const name = m.displayName?.trim()
      || [m.firstName, m.lastName].filter(Boolean).join(" ").trim()
      || m.jobTitle?.trim()
      || `Member ${(m.principalId ?? m.id).replace(/^pp_/, "").slice(0, 6)}`;
    return {
      id: m.principalId ?? m.id,
      initials: name.split(/\s+/).filter(Boolean).slice(0, 2).map((p) => p[0]).join("").toUpperCase() || "?",
      name,
      email: m.email ?? "",
    };
  }), [membersQ.data]);

  const refetch = useCallback(() => { listQ.refetch(); statsQ.refetch(); }, [listQ, statsQ]);
  const cmd = useCallback(async (action: string, variables: Record<string, unknown>) => {
    await sdk.command(action, variables);
    refetch();
  }, [sdk, refetch]);

  const leads = useMemo(() => (listQ.data?.items ?? []).map(cardToLead), [listQ.data]);

  const byStatus = useMemo(() => {
    if (statsQ.data?.byStatus) return { ...EMPTY_BY_STATUS, ...statsQ.data.byStatus };
    const m = { ...EMPTY_BY_STATUS };
    for (const l of leads) m[l.status] += 1;
    return m;
  }, [statsQ.data, leads]);

  const getLead = useCallback(async (lead: Lead): Promise<Lead> => {
    const dto = await sdk.query<LeadDTO>("crm.lead.get", { id: lead.refId ?? lead.id });
    return detailToLead(dto, lead, reps);
  }, [sdk, reps]);

  return {
    live: true,
    loading: listQ.loading || statsQ.loading,
    error: (listQ.error ?? statsQ.error)?.message ?? null,
    leads,
    total: statsQ.data?.total ?? listQ.data?.meta?.total ?? leads.length,
    byStatus,
    reps,
    getLead,
    uploadPhoto,
    getPhotoUrl,
    addPhoto: async (lead, attachment) => {
      await sdk.command("crm.lead.attachment.add", { id: lead.refId ?? lead.id, attachment });
    },
    removePhoto: async (lead, attachment) => {
      await sdk.command("crm.lead.attachment.remove", { id: lead.refId ?? lead.id, attachmentId: attachment.id });
    },
    // Photos are NOT sent in the create payload — they persist via the dedicated
    // crm.lead.attachment.add command, keyed by the freshly-created lead's id (§ postman #24).
    createLead: async (input) => {
      const { photos, ...rest } = input;
      // bffCommand (not sdk.command) so the backend's duplicate_lead / validation message
      // reaches the toast instead of the SDK's opaque "data command failed: 400".
      const created = await bffCommand<{ id: string }>("crm.lead.create", rest);
      for (const attachment of photos ?? []) {
        await sdk.command("crm.lead.attachment.add", { id: created.id, attachment });
      }
      refetch();
    },
    updateLead: (lead, patch) => cmd("crm.lead.update", { id: lead.refId ?? lead.id, patch }),
    updateStatus: (lead, status, note) => cmd("crm.lead.updateStatus", { id: lead.refId ?? lead.id, status, ...(note ? { note } : {}) }),
    assign: (lead, assigneeId) => cmd("crm.lead.assign", { id: lead.refId ?? lead.id, assigneeId }),
    // Push into the verification queue. Idempotent server-side (returns the existing verification
    // if the lead was already sent). Refetch so the lead's verificationId lands and the guard persists.
    sendForVerification: async (lead) => {
      await sdk.command("crm.lead.sendForVerification", { id: lead.refId ?? lead.id });
      refetch();
    },
    refetch,
  };
}

/* ---- public hook: pick the implementation at module-config time --------- */

const SHELL = isShellConfigured();

export function useLeadsData(): LeadsData {
  // SHELL is a build-time constant, so the same hook path runs every render (Rules-of-Hooks safe).
  return SHELL ? useLiveLeads() : useMockLeads();
}
