// ---------------------------------------------------------------------------
// automation/AutomationFind.tsx — the discovery filter, as TOGGLES (owner item 6,
// contract C4).
//
// WHAT CHANGED AND WHY. It was a condition BUILDER: an empty row, a field
// dropdown listing 21 names, "Add a condition". That shape asks the user to
// remember what is searchable before they can look, and it hid the only fact
// that decides whether a search returns anything at all — which fields actually
// carry values. The corpus is 620 million profiles and every run is billed, so
// "I did not know I could filter on that" and "I filtered on a field that is
// empty on every row" are both expensive mistakes made in silence.
//
// So every searchable field is ON SCREEN, off by default, and turning one on is
// what creates its condition. The list leads with the fields MEASURED to carry
// values; the rest are grouped under what is known about them, and neither group
// is hidden — R3 already decided that "every filter" means the 21 we can stand
// behind, so all 21 are visible.
//
// ⛔ THE VOCABULARY IS THE SERVER'S, ALL OF IT. Field names, operators, which
// operators take no value, the record ceiling and (once C4 lands it) which
// fields are populated and which narrow — every one of those rides on
// `GET /automations`. This file holds no list of its own. The reason is the one
// `automationApi.ts:11-15` gives for cron presets: the thing that ACCEPTS a
// filter is Python, and a client copy of what it accepts is a copy that can
// offer a search the server refuses.
//
// ⛔ AND IT DOES NOT RE-IMPLEMENT THE GUARD. `guard` carries the server's
// numbers, so the rows can say which fields narrow — but the refusal is the
// server's to make and this surface prints it VERBATIM when it comes back
// (`DiscoverGuard`'s note). A client that predicts the refusal is a second copy
// of the rule, free to disagree with the first, and when they disagree the user
// gets a Save button that is disabled for a reason nobody can see.
// ---------------------------------------------------------------------------
import { useEffect, useState } from "react";
// ⭐ WAVE 27 item 29 — the overlay layer the grid already owns, consumed rather than
// re-implemented: it is a body portal (so it escapes `.auto-panel`'s 380px), and it brings
// the dismiss/focus/placement behaviour every other popover on this product already has.
import { AnchoredOverlay } from "../customer-grid/OverlaySurface";
import type { AnchorRect } from "../customer-grid/OverlaySurface";

import type {
  DiscoverEstimate,
  DiscoverFieldMeta,
  DiscoverOperator,
  DiscoverVocab,
  Predicate,
} from "./automationApi";
import { discoverCategories } from "./automationApi";

interface Props {
  /** The server-declared discovery vocabulary (absent until the list loads). */
  discover?: DiscoverVocab;
  recordsLimit: number;
  onRecordsLimit: (n: number) => void;
  joinOp: string;
  onJoinOp: (v: string) => void;
  preds: Predicate[];
  onPreds: (next: Predicate[]) => void;
  estimate: DiscoverEstimate | null;
  onEstimate: () => void;
}

/**
 * A FILTER FIELD'S ICON (owner item 5). Drawn, `currentColor`, never an emoji — and in the
 * existing `TriggerMark`/`ActionMark` language rather than a new one: same 16 viewBox, same 15px
 * box, same 1.4 stroke, so a row here and a card in the builder read as one product.
 *
 * ⚠ SHARED SHAPES WHERE THE FIELDS SHARE A MEANING, distinct everywhere else. `TriggerMark`'s
 * note settled the principle — a column of identical glyphs is decoration, the eye learns nothing
 * — but its converse matters just as much here: `bio_hashtags` and `post_hashtags` ARE both
 * hashtags, and drawing two different marks for them would invent a distinction the vendor does
 * not make. Twenty-one contrived glyphs would be twenty-one things to misread.
 *
 * ⛔ THE FALLBACK IS NOT A MEMBER OF THE SET, which is the `ActionMark` scar exactly: its default
 * used to BE the pencil, a real member, so every unmatched kind silently borrowed "edit" and a
 * fallback could not be told from a match. The vocabulary is the SERVER's — it can grow a field
 * tomorrow — so an unmatched name draws a neutral mark that is deliberately meaningless.
 */
function FilterMark({ name }: { name: string }) {
  const common = {
    width: 15, height: 15, viewBox: "0 0 16 16", fill: "none", stroke: "currentColor",
    strokeWidth: 1.4, strokeLinecap: "round" as const, strokeLinejoin: "round" as const,
    "aria-hidden": true,
  };
  // Audience size — two figures, one behind the other.
  if (name === "followers")
    return (
      <svg {...common}>
        <circle cx="6.2" cy="5.6" r="2.4" />
        <path d="M2.2 13c.5-2 2.1-3.2 4-3.2s3.5 1.2 4 3.2" />
        <path d="M10.6 3.6a2.4 2.4 0 0 1 0 4M11.4 9.9c1.4.4 2.4 1.5 2.8 3.1" />
      </svg>
    );
  // Who this account follows — a figure with an outbound arrow.
  if (name === "following")
    return (
      <svg {...common}>
        <circle cx="6" cy="5.6" r="2.4" />
        <path d="M1.8 13c.5-2 2.1-3.2 4.2-3.2 .6 0 1.2.1 1.7.3" />
        <path d="M9.8 10.6h4.2M12.2 8.8l1.8 1.8-1.8 1.8" />
      </svg>
    );
  if (name === "posts_count")
    return (
      <svg {...common}>
        <rect x="2.6" y="2.6" width="4.6" height="4.6" rx="1" />
        <rect x="8.8" y="2.6" width="4.6" height="4.6" rx="1" />
        <rect x="2.6" y="8.8" width="4.6" height="4.6" rx="1" />
        <rect x="8.8" y="8.8" width="4.6" height="4.6" rx="1" />
      </svg>
    );
  // A story highlight — the ring Instagram draws around one.
  if (name === "highlights_count")
    return (
      <svg {...common}>
        <circle cx="8" cy="8" r="5.6" strokeDasharray="2.6 1.8" />
        <circle cx="8" cy="8" r="2.2" />
      </svg>
    );
  if (name === "avg_engagement")
    return (
      <svg {...common}>
        <path d="M8 13.2 3.4 8.8a2.9 2.9 0 0 1 4.6-3.4 2.9 2.9 0 0 1 4.6 3.4z" />
      </svg>
    );
  if (name === "biography")
    return (
      <svg {...common}>
        <rect x="2.4" y="2.8" width="11.2" height="10.4" rx="1.6" />
        <path d="M4.8 6h6.4M4.8 8.4h6.4M4.8 10.8h3.6" />
      </svg>
    );
  if (name === "category_name" || name === "business_category_name")
    return (
      <svg {...common}>
        <path d="M8.4 2.6H13v4.6l-6.2 6.2-4.6-4.6z" />
        <circle cx="10.8" cy="5.2" r="0.9" />
      </svg>
    );
  if (name === "is_business_account" || name === "is_professional_account")
    return (
      <svg {...common}>
        <rect x="2.2" y="5" width="11.6" height="8.2" rx="1.6" />
        <path d="M6 5V3.6a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1V5" />
      </svg>
    );
  if (name === "is_verified")
    return (
      <svg {...common}>
        <path d="M8 2.2l1.7 1.2 2-.2.6 2 1.7 1.1-.9 1.9.9 1.9-1.7 1.1-.6 2-2-.2L8 14.2l-1.7-1.2-2 .2-.6-2L2 10.1l.9-1.9L2 6.3l1.7-1.1.6-2 2 .2z" />
        <path d="m5.8 8.1 1.5 1.5 3-3.1" />
      </svg>
    );
  // The handle, and the display name it is written under.
  if (name === "account" || name === "profile_name")
    return (
      <svg {...common}>
        <circle cx="8" cy="8" r="2.4" />
        <path d="M10.4 5.6v3.2a1.9 1.9 0 0 0 3.2 1.3A5.8 5.8 0 1 0 11 13.1" />
      </svg>
    );
  if (name === "full_name")
    return (
      <svg {...common}>
        <circle cx="8" cy="5.6" r="2.6" />
        <path d="M3 13.4c.7-2.3 2.6-3.6 5-3.6s4.3 1.3 5 3.6" />
      </svg>
    );
  if (name === "external_url" || name === "profile_url")
    return (
      <svg {...common}>
        <path d="M6.6 9.4 4.9 11a2.6 2.6 0 1 1-1.5-4.4" />
        <path d="M9.4 6.6 11.1 5a2.6 2.6 0 1 1 1.5 4.4" />
        <path d="M6.2 8h3.6" />
      </svg>
    );
  if (name === "bio_hashtags" || name === "post_hashtags")
    return (
      <svg {...common}>
        <path d="M6.2 2.8 4.8 13.2M11.2 2.8 9.8 13.2M2.8 6h10.4M2.2 10h10.4" />
      </svg>
    );
  if (name === "related_accounts")
    return (
      <svg {...common}>
        <circle cx="8" cy="3.6" r="1.8" />
        <circle cx="3.6" cy="12" r="1.8" />
        <circle cx="12.4" cy="12" r="1.8" />
        <path d="M6.8 5.2 4.6 10.4M9.2 5.2l2.2 5.2M5.4 12h5.2" />
      </svg>
    );
  // The vendor's own record keys — the thing you hold to open exactly one row.
  if (name === "id" || name === "fbid")
    return (
      <svg {...common}>
        <circle cx="5.4" cy="10.6" r="2.6" />
        <path d="M7.2 8.8 13 3M10.6 5.4l1.6 1.6M9.2 6.8l1.6 1.6" />
      </svg>
    );
  return (
    <svg {...common}>
      <rect x="3.2" y="3.2" width="9.6" height="9.6" rx="2.2" />
      <circle cx="8" cy="8" r="1.4" />
    </svg>
  );
}

/** One field's line in the list, with everything known about it. */
interface FieldRow {
  name: string;
  /** What the row is CALLED. Falls back to the raw name only if the server sent none. */
  label: string;
  hint: string;
  kind: string;
  operators: DiscoverOperator[];
  options: { value: string; label: string }[];
  /** Every stored condition naming this field, with its index in the saved array. */
  at: number[];
  /** MEASURED to carry values. `null` = the server has not said (C4 not shipped yet). */
  populated: boolean | null;
  /** A condition here cuts the corpus down. `null` = not said. */
  narrowing: boolean | null;
}

/** A stored value → what the text box shows. A list is the "any of" shape. */
function valueText(v: Predicate["value"]): string {
  if (Array.isArray(v)) return v.join(", ");
  return v === undefined || v === null ? "" : String(v);
}

/**
 * What the user typed → what is stored. Comma-separated becomes a LIST for the operators that
 * accept one, and stays a plain string for the ones that do not.
 *
 * ⚠ A single value stays a SCALAR rather than a one-element list — the server sends scalars
 * flat and lists as a nested OR group, and a one-element group is a shape nothing has been
 * billed against.
 */
function parseValue(text: string, multi: boolean): Predicate["value"] {
  if (!multi) return text;
  const parts = text.split(",").map((s) => s.trim()).filter(Boolean);
  if (parts.length > 1) return parts;
  // Keep the RAW text while it is still being typed — trimming here would fight the cursor on
  // every keystroke, and the server trims anyway.
  return text;
}

/**
 * ⭐ WAVE 26 · DEBT D-69 — A THIRD VALUE COULD NOT BE TYPED, and the cause was one round trip
 * through this pair of functions.
 *
 * `parseValue` returns an ARRAY at 2+ values; `valueText` then renders that array as
 * `join(", ")` — which has NO TRAILING COMMA. So on a controlled input the separator was
 * normalised away between keystrokes: typing `,` onto `skincare, beauty` re-rendered as
 * `skincare, beauty`, and the next character landed as `skincare, beautym`. Two keywords
 * silently became one that matches nothing. ⚠ It hit the FIRST surface a new user meets (wave 24
 * made this panel the only door) and it hit precisely the 3+ keyword list the feature was built
 * for — the owner's *"if i want to include many keywords like floral, flower, beauty"*.
 *
 * ⛔ THE FIX IS THE ONE `parseValue`'s OWN COMMENT ALREADY DESCRIBES FOR THE 1-VALUE CASE:
 * keep the raw text while it is being typed. That comment was right and its scope was too narrow
 * — it protected the cursor at one value and handed the 2+ case to `join`. So the raw text is
 * held for whichever input is FOCUSED, and the stored value is still parsed on every keystroke
 * (so nothing depends on blur to save) and re-parsed on blur (so the display settles).
 *
 * ⚠ KEYED BY THE ROW'S REACT KEY, not by index alone: two conditions on the same field are
 * ordinary here ("Another condition on Bio"), and an index-only key would leak one row's draft
 * into its sibling on a removal.
 */
interface Draft {
  key: string;
  text: string;
}

/**
 * The comparison a freshly toggled field starts with — ASKED FOR, never chosen here.
 *
 * ⛔ THE THIRD COSTUME OF ONE SCAR, and the reason this function no longer has an opinion.
 * Wave 21 made a new condition NULLARY (`is_not_null`) so it would be saveable without a
 * value, and called that "narrowing-in-the-right-direction". Wave 22 then wrote the narrowing
 * law server-side and `is_not_null` is not in it. From that moment, toggling ANY field on and
 * pressing Save returned "add at least one CONTENT condition…" — the error told the user to do
 * the exact thing they had just done. Wave 24 deleted the wizard and made this panel the only
 * door, so it stopped being a corner case and became the first thing a new user hits.
 *
 * The fix is not a better guess. It is that the module owning the guard also names the default
 * (`automation_engine.default_operator`, asserted against `predicate_narrows` in
 * `verify_automation.py`), and this file looks it up per FIELD. An absent one falls back to the
 * server's first operator — never to a nullary, which is the thing that could not narrow.
 */
function firstOperator(v: DiscoverVocab | undefined, name: string): string {
  const meta = (v?.filterMeta || []).find((m) => m.name === name);
  return meta?.defaultOperator || (v?.operators || [])[0] || "";
}

/**
 * The rows, ordered: measured-populated first, then the rest.
 *
 * TWO SOURCES, AND THE FALLBACK IS NARROWER THAN THE REAL THING. With C4's
 * `filterMeta` the answer is per field and complete. Without it, `lead` names
 * the three fields seen carrying values and says NOTHING about the other
 * eighteen — so those come back `null`, not `false`, and the UI prints no claim
 * about them rather than an invented one.
 */
function buildRows(preds: Predicate[], v?: DiscoverVocab): FieldRow[] {
  const meta = new Map<string, DiscoverFieldMeta>(
    (v?.filterMeta || []).map((m) => [m.name, m])
  );
  const lead = new Set(v?.lead || []);
  const byName = new Map<string, number[]>();
  preds.forEach((p, i) => {
    const list = byName.get(p.name);
    if (list) list.push(i);
    else byName.set(p.name, [i]);
  });

  const rows = (v?.fields || []).map((name) => {
    const m = meta.get(name);
    return {
      name,
      label: m?.label || name,
      hint: m?.hint || "",
      kind: m?.kind || "text",
      // ⛔ NO FALLBACK TO THE GLOBAL OPERATOR LIST. An older server that sends no per-field list
      // gets a row with no comparisons rather than all fourteen on every field — visibly
      // unfinished beats quietly offering `at least` on a yes/no column.
      operators: m?.operators || [],
      options: m?.options || [],
      at: byName.get(name) || [],
      populated: m ? !!m.populated : lead.has(name) ? true : null,
      narrowing: m ? !!m.narrowing : null,
    };
  });
  // Stable partition, never a sort: the server's order inside each half is the
  // order the engine lists them in, and re-alphabetising it would be this file
  // having an opinion about a server list.
  return [...rows.filter((r) => r.populated === true), ...rows.filter((r) => r.populated !== true)];
}

export default function AutomationFind({
  discover,
  recordsLimit,
  onRecordsLimit,
  joinOp,
  onJoinOp,
  preds,
  onPreds,
  estimate,
  onEstimate,
}: Props) {
  const rows = buildRows(preds, discover);
  const known = new Set(discover?.fields || []);
  // The GLOBAL operator list is gone from this component: comparisons are per field now
  // (`row.operators`). `nullary` survives for STRAYS only — a stored condition on a field the
  // vendor dropped has no per-field row to read from, and it still has to render to be removable.
  const nullary = new Set(discover?.nullaryOperators || []);
  const withValues = rows.filter((r) => r.populated === true);
  const rest = rows.filter((r) => r.populated !== true);
  const knownMeta = !!(discover?.filterMeta || []).length;
  // ⚠ THE STORED FILTER CAN NAME A FIELD THE LIST NO LONGER HAS — a vocabulary
  // that moved, an automation copied from elsewhere. It must stay VISIBLE and
  // REMOVABLE: a condition the user cannot see is one they cannot delete, and
  // every Save keeps sending it. Same defect as a `<select>` whose value matches
  // no option, one level up.
  const strays = preds
    .map((p, i) => ({ p, i }))
    .filter(({ p }) => !known.has(p.name));

  const maxRecords = discover?.guard?.maxRecords || discover?.maxRecords || 500;

  /** D-69 — the raw text of the input a person is typing in right now (see `Draft`). */
  const [draft, setDraft] = useState<Draft | null>(null);

  /**
   * ⭐ WAVE 27 · OWNER ITEM 29 — which condition's value is open in the BIG editor, if any.
   *
   * ⛔ THE TEXT IS HELD HERE, NOT WRITTEN THROUGH ON EVERY KEYSTROKE, and that is the one place
   * this differs from the rail input beside it. The rail input writes per keystroke on purpose
   * (a Save pressed straight from the field must not store the previous value — D-69's note).
   * A modal has its own Save, so per-keystroke writes would buy nothing and would make Cancel
   * a lie: the value would already be in the config by the time it was pressed.
   * ⚠ The ANCHOR is captured at click time and stored, because the button it came from is
   * inside a panel that scrolls — re-reading it later would place the overlay against a rect
   * that has moved.
   */
  const [big, setBig] = useState<{
    key: string;
    index: number;
    label: string;
    multi: boolean;
    text: string;
    anchor: AnchorRect;
  } | null>(null);

  /**
   * ⭐ ITEM 16 / D-71 — THE OBSERVED CATEGORY VALUES, fetched WHEN THIS PANEL OPENS.
   *
   * ⛔ ON MOUNT, ONCE, AND NEVER ON THE POLLED PAYLOAD. This component is rendered only while the
   * Find panel is open, so mounting IS the panel opening — and the server moved these off
   * `GET /automations` after measuring that deriving them reads two user tables plus the platform
   * master, i.e. a network round-trip per open tab every 2.5 s. Putting the request here is the
   * client half of that same decision, not an optimisation.
   *
   * ⚠ AN EMPTY LIST IS AN ANSWER AND IS RENDERED AS ONE. It means this deployment has not seen a
   * category value yet — which is TRUE for a fresh tenant — and the field keeps its free-text
   * input either way. It must never read as "loading forever", and it must never be filled in
   * with a guess: D-59 is explicit that an invented taxonomy is worse than no dropdown.
   */
  const [cats, setCats] = useState<{ value: string; count: number }[]>([]);
  useEffect(() => {
    const ac = new AbortController();
    discoverCategories(ac.signal)
      .then((r) => setCats(Array.isArray(r?.options) ? r.options : []))
      // Fail-quiet, on purpose: the picker is an ASSIST over a control that works without it.
      // An error banner here would report a broken panel when the only thing missing is a
      // convenience — and the free-text input beside it is unaffected.
      .catch(() => setCats([]));
    return () => ac.abort();
  }, []);

  const setAt = (i: number, patch: Partial<Predicate>) =>
    onPreds(preds.map((p, j) => (j === i ? { ...p, ...patch } : p)));

  const removeAt = (i: number) => onPreds(preds.filter((_p, j) => j !== i));

  const toggleField = (row: FieldRow) => {
    if (row.at.length) {
      onPreds(preds.filter((p) => p.name !== row.name));
      return;
    }
    onPreds([...preds, { name: row.name, operator: firstOperator(discover, row.name) }]);
  };

  /** One condition line — the comparison, its value, and a way out. */
  const condition = (i: number, label: string, row?: FieldRow) => {
    const p = preds[i];
    if (!p) return null;
    // The field's OWN comparisons. `row` is absent only for a stray (a stored condition on a
    // field the vendor no longer offers), which keeps the raw token so it stays removable.
    const ops = row?.operators || [];
    const cur = ops.find((o) => o.value === p.operator);
    const isNullary = cur ? cur.nullary : nullary.has(p.operator);
    const isMulti = !!cur?.multi;
    const options = row?.options || [];
    const rowKey = `${p.name}-${i}`;
    // D-69: the focused input shows what was TYPED; every other one shows the stored value.
    const shown = draft && draft.key === rowKey ? draft.text : valueText(p.value);
    /*
     * ⭐ ITEM 16 / D-71 — WHICH FIELDS GET THE OBSERVED-VALUES PICKER, DERIVED OFF THE WIRE.
     *
     * ⛔ NOT A CLIENT-SIDE LIST OF FIELD NAMES. Naming the vendor's two category columns here
     * would be the D-55 class verbatim — the wave-9 silent-drop shape, a client copy of a server
     * vocabulary that is free to drift the day the engine adds a third one. (Their literal keys
     * are deliberately not written anywhere in this file: the gate asserts the tokens are ABSENT,
     * and an absence check cannot tell a hard-coded list from the comment forbidding one.)
     * The server already publishes the answer twice over: `kind: "choice"` is its own declaration
     * (`BD_FIELD_KINDS`), and `filter_meta` deliberately ships `options: []` for those fields
     * because their vocabulary is OBSERVED rather than declared and arrives on its own route.
     * So the rule is exactly that: a choice-kind field with no declared options is one whose
     * values we can only have learned by seeing them. A new choice field inherits this for free.
     */
    const wantsObserved = row?.kind === "choice" && !options.length;
    /** D-71 — APPEND, never replace: the picker has to compose with the comma list. */
    const appendValue = (v: string) => {
      const has = shown
        .split(",")
        .map((s) => s.trim().toLowerCase())
        .filter(Boolean);
      // Picking the same value twice is a no-op rather than a duplicate: the server expands a
      // multi-value condition into an OR, and `beauty OR beauty` costs a slot to say nothing.
      if (has.includes(v.trim().toLowerCase())) return;
      const next = shown.trim() ? `${shown.replace(/[\s,]+$/, "")}, ${v}` : v;
      setDraft(null);
      setAt(i, { value: parseValue(next, isMulti) });
    };
    return (
      <div className="autob-cond" key={rowKey}>
        <select
          className="auto-input is-small"
          aria-label={`Comparison for ${label}`}
          value={p.operator}
          onChange={(e) => setAt(i, { operator: e.target.value })}
        >
          {/* The stored value is ALWAYS an option. A select whose value matches no
              option renders the FIRST one, and the next Save writes a comparison
              nobody chose — this codebase has paid for that twice. */}
          {p.operator && !ops.some((o) => o.value === p.operator) ? (
            <option value={p.operator}>{p.operator}</option>
          ) : null}
          {ops.map((o) => (
            <option key={o.value} value={o.value}>
              {o.label}
            </option>
          ))}
        </select>
        {isNullary ? null : options.length ? (
          // A fixed vocabulary gets a dropdown. Owner item 2: a yes/no column was a box you
          // typed `true` into, beside a comparison list that offered "at least".
          <select
            className="auto-input is-small"
            aria-label={`Value for ${label}`}
            value={valueText(p.value)}
            onChange={(e) => setAt(i, { value: e.target.value })}
          >
            <option value="">Choose…</option>
            {options.map((o) => (
              <option key={o.value} value={o.value}>
                {o.label}
              </option>
            ))}
          </select>
        ) : (
          <input
            className="auto-input is-small"
            type={row?.kind === "number" ? "number" : "text"}
            aria-label={`Value for ${label}`}
            // ⭐ OWNER ITEM 3. Several comma-separated values become an "any of" condition, which
            // the server sends as its own OR group — so it does NOT drag Followers into a union
            // the way the Match dropdown would.
            placeholder={isMulti ? "floral, flower, beauty" : ""}
            // D-69: `shown` is the RAW text while this input has focus, so a trailing comma
            // survives long enough to type the next value after it.
            value={shown}
            onFocus={() => setDraft({ key: rowKey, text: valueText(p.value) })}
            onChange={(e) => {
              setDraft({ key: rowKey, text: e.target.value });
              // The STORE still updates on every keystroke. Parsing only on blur would mean a
              // Save pressed straight from the field wrote the previous value — trading a typing
              // bug for a data-loss one.
              setAt(i, { value: parseValue(e.target.value, isMulti) });
            }}
            onBlur={(e) => {
              setDraft(null);
              setAt(i, { value: parseValue(e.target.value, isMulti) });
            }}
          />
        )}
        {/* ⭐ WAVE 27 · OWNER ITEM 29 — A WIDE EDITOR FOR THE VALUES THAT DO NOT FIT.
            ⛔ THE COMPLAINT IS GEOMETRY, and the geometry is a CHAIN nothing here can widen:
            `.auto-panel` is 380px, `.autob-cond .auto-input` divides what is left across three
            controls, and `.is-small` trims it again — so a bio phrase list ("floral, flower,
            wedding florist, event styling") is typed six characters at a time through a box
            that scrolls sideways. The panel cannot grow; the EDITOR can leave it, and an
            overlay is drawn in a body portal, so it is not bound by that chain at all.
            ⚠ ALWAYS OFFERED, never "appears once the text is long": a control that materialises
            at some threshold is one people do not know exists at the moment they need it.
            ⚠ NOT on a `<select>` value (a fixed vocabulary has nothing to expand) and not on a
            nullary comparison (which has no value at all). */}
        {!isNullary && !options.length ? (
          <button
            type="button"
            className="auto-input is-small autoc-expand"
            aria-label={`Open a bigger editor for ${label}`}
            title="Edit in a bigger box"
            onClick={(e) => {
              // ⚠ READ SYNCHRONOUSLY from the event. `currentTarget` is null by the time React
              // re-invokes a state updater, and a null rect here paints the overlay at 0,0 —
              // measured, in this repo, on a different control ([[react-event-currenttarget-updater]]).
              const r = e.currentTarget.getBoundingClientRect();
              setBig({
                key: rowKey,
                index: i,
                label,
                multi: isMulti,
                text: valueText(p.value),
                anchor: { left: r.left, top: r.top, right: r.right, bottom: r.bottom,
                          width: r.width, height: r.height },
              });
            }}
          >
            {/* Drawn, never a glyph: the design constitution's "no emojis in the UI" covers
                the arrow characters too, and every other mark in this product is a path in the
                same 16x16 stroke vocabulary. Two corners pulling apart = "make this bigger". */}
            <svg viewBox="0 0 16 16" aria-hidden className="autoc-expand-icon">
              <path d="M9.5 2.5h4v4M13.5 2.5 9.6 6.4" />
              <path d="M6.5 13.5h-4v-4M2.5 13.5l3.9-3.9" />
            </svg>
          </button>
        ) : null}
        {/* ⭐ ITEM 16 / D-71 — THE APPEND-PICKER, BESIDE the input and never instead of it.
            Free text stays the primary control: these are the values this deployment has SEEN,
            not the values that exist, so the picker is a shortcut over a superset it cannot
            enumerate. Appending in code also sidesteps D-69 entirely — a picked value never has
            to survive a round trip through the text box. */}
        {wantsObserved && !isNullary ? (
          <select
            className="auto-input is-small autoc-catpick"
            aria-label={`Add a value seen before, to ${label}`}
            title={
              cats.length
                ? "Values this workspace has actually seen, most-seen first. Adds to the list."
                : "No category values have been seen in your data yet — type one."
            }
            value=""
            disabled={!cats.length}
            onChange={(e) => {
              if (e.target.value) appendValue(e.target.value);
            }}
          >
            <option value="">{cats.length ? "Seen before…" : "None seen yet"}</option>
            {cats.map((c) => (
              // The COUNT is the honest half (D-59): a value seen once and one seen forty times
              // are different bets, and an alphabetical list of bare strings hides that.
              <option key={c.value} value={c.value}>
                {c.value} ({c.count.toLocaleString()})
              </option>
            ))}
          </select>
        ) : null}
        {isMulti && Array.isArray(p.value) && p.value.length > 1 ? (
          /*
           * ⭐ ITEM 16 / D-71 — HOW MANY VALUES THIS CONDITION CARRIES, and against what ceiling.
           *
           * The COUNT is ours and is always shown. The CEILING is the server's and is shown only
           * when the server sends one (`guard.maxValues` — an open `ASK ->A:`; `guard` ships
           * `{minNarrowing, maxRecords}` today).
           * ⛔ NO CLIENT-SIDE `4`. D-71's exit says "how many of D-68's 4 value slots" — but A
           * shipped `depth_refusal()`, which computes the depth of the shape actually emitted
           * rather than budgeting fixed slots, so the number 4 describes a model that no longer
           * exists. Printing it would be a client copy of a server rule that has already changed
           * once, which is the specific failure this file's header refuses to commit
           * ([[measure-the-real-call]]: a correct-looking answer about the wrong subject).
           */
          <span className="autob-cond-any" title="Any one of these is a match">
            any of {p.value.length}
            {discover?.guard?.maxValues ? ` of ${discover.guard.maxValues}` : ""}
          </span>
        ) : null}
        <button
          type="button"
          className="autob-cond-x"
          aria-label={`Remove this condition on ${label}`}
          title="Remove this condition"
          onClick={() => removeAt(i)}
        >
          <svg width="12" height="12" viewBox="0 0 16 16" fill="none" aria-hidden="true">
            <path d="M4 4l8 8M12 4l-8 8" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
          </svg>
        </button>
      </div>
    );
  };

  const fieldRow = (row: FieldRow) => {
    const on = row.at.length > 0;
    return (
      <div className={"autob-tgl" + (on ? " is-on" : "")} key={row.name}>
        <div className="autob-tgl-head">
          <button
            type="button"
            className={"auto-step-switch autob-tgl-switch" + (on ? " is-on" : "")}
            aria-pressed={on}
            aria-label={`${on ? "Stop searching" : "Search"} on ${row.label}`}
            title={`${on ? "Stop searching" : "Search"} on ${row.label}`}
            onClick={() => toggleField(row)}
          >
            <span className="auto-step-switch-knob" />
          </button>
          <span className="autob-tgl-mark">
            <FilterMark name={row.name} />
          </span>
          {/* ⭐ THE HUMAN LABEL (owner item 1: "wtf is id/fbid").
              This used to render the vendor's own column name verbatim, and the comment
              defending it was RIGHT about the reason — a refusal names the field, and a label
              that reads differently makes the error unmatchable. So the label did not move
              alone: `field_label()` is what the SERVER's refusals use too, which is the only
              version of this change that does not trade one confusion for another. */}
          <span className="autob-tgl-name" title={row.hint || undefined}>
            {row.label}
          </span>
          {/*
            ⚠ ONE CHIP, AND IT MARKS THE MINORITY. The first version chipped every
            non-narrowing field "broad" AND every unpopulated one "no values seen" —
            which put two chips on 18 of the 21 rows, and one of them repeated the group
            heading the row was already sitting under. A mark that is on almost every row
            stops being a mark. Read off the screenshot; no assertion could have said it.

            So: the group label carries POPULATED (it is what the groups ARE), and the
            chip carries NARROWING, inverted to mark the five that DO narrow. That is the
            actionable half — the server refuses a filter with no narrowing condition, so
            "these are the ones that satisfy it" is the sentence a user needs, and
            "this one does not" is not.
          */}
          {row.narrowing === true ? (
            <span
              className="autob-tgl-flag is-narrow"
              title="A search needs at least one condition like this."
            >
              narrows
            </span>
          ) : null}
        </div>
        {on ? (
          <div className="autob-tgl-body">
            {row.at.map((i) => condition(i, row.label, row))}
            {row.hint ? <p className="autob-tgl-hint">{row.hint}</p> : null}
            <button
              type="button"
              className="autob-cond-add"
              onClick={() =>
                onPreds([...preds, { name: row.name, operator: firstOperator(discover, row.name) }])
              }
            >
              Another condition on {row.label}
            </button>
          </div>
        ) : null}
      </div>
    );
  };

  return (
    <>
      <div className="auto-field">
        <label htmlFor="auto-records">How many profiles to fetch</label>
        <input
          id="auto-records"
          className="auto-input"
          type="number"
          min={1}
          max={maxRecords}
          value={recordsLimit}
          onChange={(e) => onRecordsLimit(Number(e.target.value) || 1)}
        />
      </div>
      {/* Owner item 5: "Stop at the 'A search must be at most 500 profiles a run'". The two
          sentences that followed explained the vendor's billing model to somebody who wants a
          list of florists. */}
      <p className="auto-hint">At most {maxRecords.toLocaleString()} profiles a run.</p>

      <h3>Conditions</h3>
      <div className="auto-field autob-match">
        <label htmlFor="auto-joinop">Match</label>
        <select
          id="auto-joinop"
          className="auto-input"
          value={joinOp}
          onChange={(e) => onJoinOp(e.target.value)}
        >
          <option value="and">All of them</option>
          <option value="or">Any of them</option>
        </select>
      </div>

      {!rows.length ? (
        <p className="auto-note">
          The searchable fields have not loaded yet.
        </p>
      ) : null}

      {withValues.length ? (
        <div className="autob-tglgroup">
          <p
            className="autob-tglgroup-label"
            title="Most accounts have this filled in."
          >
            Usually filled in
          </p>
          {withValues.map(fieldRow)}
        </div>
      ) : null}

      {rest.length ? (
        <div className="autob-tglgroup">
          <p
            className="autob-tglgroup-label"
            title={
              knownMeta
                ? "Most accounts leave these blank, so a condition here can return nothing."
                : "We have not measured how often these are filled in."
            }
          >
            {knownMeta ? "Often blank" : "Not measured"}
          </p>
          {rest.map(fieldRow)}
        </div>
      ) : null}

      {strays.length ? (
        <div className="autob-tglgroup">
          <p className="autob-tglgroup-label">Not in the searchable list</p>
          {strays.map(({ p, i }) => (
            <div className="autob-tgl is-on is-stray" key={`stray-${i}`}>
              <div className="autob-tgl-head">
                {/* A stray gets a mark too — "every filter row" includes the ones the vendor no
                    longer offers, and a row missing the icon its neighbours have would read as a
                    different KIND of thing rather than as a field that fell out of the list. It
                    draws the neutral fallback by construction: a stray is, by definition, not in
                    the vocabulary this component has arms for. */}
                <span className="autob-tgl-mark">
                  <FilterMark name={p.name} />
                </span>
                <span className="autob-tgl-name">{p.name}</span>
                <span className="autob-tgl-flag is-stray" title="The server will refuse this condition by name.">
                  not searchable
                </span>
              </div>
              <div className="autob-tgl-body">{condition(i, p.name)}</div>
            </div>
          ))}
        </div>
      ) : null}

      {/* The consequence kept, the mechanism dropped: a condition on an often-blank field
          returning nothing is a thing that will happen to somebody and confuse them. Why the
          search is slow when broad is our problem, not theirs. */}
      <p className="auto-hint">
        Conditions on often-blank fields can come back with nothing.
      </p>

      <h3>What it costs</h3>
      <div className="auto-head-actions">
        <button type="button" className="auto-btn" onClick={onEstimate}>
          Estimate this search
        </button>
      </div>
      {estimate ? (
        <p className="auto-hint">
          About <strong>${estimate.usd}</strong> for {estimate.records} profiles, at $
          {estimate.unitUsd} each.{" "}
          {/*
            THE CAVEAT IS NOT OPTIONAL. The vendor never quotes a price before a run and this
            deployment cannot read its own balance, so presenting this as a billed figure
            would be inventing a measurement.
          */}
          <strong>This is an estimate</strong> — {estimate.note}.
        </p>
      ) : null}

      {/* ⭐ WAVE 27 · OWNER ITEM 29 — THE BIG EDITOR, rendered ONCE for the whole panel.
          ⛔ Not one overlay per condition row: `AnchoredOverlay` mounts a body portal and
          installs document-level dismiss/focus handlers, so N of them would be N listener
          stacks fighting over one Escape key. One overlay, told which row it is editing.
          ⚠ `role="dialog"`, not "menu": it holds a text field, and the menu layer's arrow-key
          handling would take the cursor keys away from the text being typed. */}
      {big ? (
        <AnchoredOverlay
          anchor={big.anchor}
          className="autoc-bigedit"
          placement="right-start"
          role="dialog"
          ariaLabel={`Value for ${big.label}`}
          onDismiss={() => setBig(null)}
          dataKind="find-value-editor"
        >
          <label htmlFor="autoc-bigedit-text">{big.label}</label>
          <textarea
            id="autoc-bigedit-text"
            className="autoc-bigedit-text"
            data-overlay-autofocus
            autoFocus
            rows={7}
            spellCheck={false}
            value={big.text}
            placeholder={big.multi ? "floral, flower, wedding florist, event styling" : ""}
            onChange={(e) => setBig((cur) => (cur ? { ...cur, text: e.target.value } : cur))}
            onKeyDown={(e) => {
              // Escape abandons; Ctrl/Cmd+Enter commits. A bare Enter types a NEWLINE, because
              // this is a textarea and the whole reason it exists is that the value is long.
              if (e.key === "Escape") setBig(null);
              if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
                setAt(big.index, { value: parseValue(big.text, big.multi) });
                setBig(null);
              }
            }}
          />
          {/* The same sentence the rail input's placeholder makes, said where there is room
              for it: a comma list is several values, and the server turns them into an OR. */}
          {big.multi ? (
            <p className="auto-hint">
              Separate values with commas — the search matches any of them.
            </p>
          ) : null}
          <div className="auto-head-actions">
            <button
              type="button"
              className="auto-btn auto-btn--primary"
              onClick={() => {
                setAt(big.index, { value: parseValue(big.text, big.multi) });
                setBig(null);
              }}
            >
              Done
            </button>
            <button type="button" className="auto-btn" onClick={() => setBig(null)}>
              Cancel
            </button>
          </div>
        </AnchoredOverlay>
      ) : null}
    </>
  );
}
