// ---------------------------------------------------------------------------
// automation/AutomationDetail.tsx — one automation, as a flow you can walk.
//
// THE SHAPE (wave-21 R9, replacing the W19 canvas): the automation is a column of
// NUMBERED STEPS, trigger first, and the right-hand panel shows the configuration
// of whichever step you clicked. The two halves answer different questions and
// neither used to be answerable at a glance — the steps answer "what does this
// thing do, in what order, and which step went wrong last night", the panel
// answers "what exactly is step 3 configured to do". The canvas answered the
// first question as a SHAPE; the engine executes a list, and a list is what the
// owner asked to read.
//
// ONE CONTROL PER FACT. A step's SWITCH is the only way to turn it on or off; the
// panel never carries a second checkbox for the same flag. STEP 1 IS THE ONE
// EXCEPTION AND IT IS THE SAME RULE: the trigger's picker IS its on/off, so the
// panel has no schedule face at all any more. Two controls for one boolean is how
// a UI starts disagreeing with itself.
//
// EVERY COUNT DRILLS TO ROWS. A run that says "412 updated" opens the 412
// ([[no-unverifiable-aggregates]]) — a number with no way back to its records is
// what this codebase treats as a defect rather than a summary.
// ---------------------------------------------------------------------------
import { useCallback, useEffect, useState } from "react";

import AutomationBuilder from "./AutomationBuilder";
import AutomationFind from "./AutomationFind";
import AutomationTrigger from "./AutomationTrigger";
import type {
  ActionCatalogRow,
  Automation,
  /* ⛔ `AutomationKind` LEFT WITH THE `kinds` PROP THAT NAMED IT (wave 25, D-57). `noUnusedLocals`
     is what turned the second half of that deletion into a build error, which is the type system
     doing the sweeping — the same property that made D-57 a one-change close instead of two. */
  FlowVocab,
  OAuthStatus,
  Predicate,
  RunEntry,
  RunRows,
  DiscoverEstimate,
  DiscoverVocab,
  SourcePreview,
  TickState,
  TriggerOption,
  UserTable,
} from "./automationApi";
import {
  AutomationError,
  COUNT_LABELS,
  deleteAutomation,
  discoverEstimate,
  fieldKeyFor,
  listTables,
  oauthStatus,
  patchAutomation,
  previewSource,
  runAutomation,
  runBlock,
  runRows,
  toggleNode,
} from "./automationApi";

interface Props {
  automation: Automation;
  /*
   * ⛔ `kinds` STOOD HERE AND IS DELETED (wave 25, closing DEBT D-57) — from `Props` AND from
   * `AutomationSurface`'s call site, in ONE change, because either alone is a `tsc` error.
   *
   * It was REQUIRED and had NO READER for a whole wave: `.auto-kindtag` was its last consumer and
   * item 15b emptied the header that carried it (R6 — the KIND stopped being a user-facing fact
   * when every new automation became `plain`). The register's own words for that state: "dead
   * weight held in place by the type system, which is the honest state but not a resting place."
   * It survived only because retiring it crossed an ownership fence in wave 24; wave 25 gives
   * both files to one session, which is the entire reason it can close.
   *
   * ⚠ THE SERVER'S `kinds` PAYLOAD KEY IS NOT TOUCHED HERE. `GET /automations` still sends it and
   * `AutomationList.kinds` still parses it — that is SESSION B's file and its decision. What is
   * asserted client-side is only that nothing in this tree reads it any more.
   */
  /** The server's schedule vocabulary, forwarded to Step 1's Custom face. */
  cronPresets: { cron: string; label: string }[];
  paidReady: boolean;
  /** The server-declared discovery vocabulary (absent until the list loads). */
  discover?: DiscoverVocab;
  /** The server's trigger vocabulary (C3). Absent until the engine ships it. */
  triggers?: TriggerOption[];
  /** The ACTION MENU and the builder's ceilings (C4). Same rule: absent is a state, not a gap. */
  catalog?: ActionCatalogRow[];
  vocab?: FlowVocab;
  /**
   * Can a schedule fire here? `null` = the server did not say (C6 amendment #1 is not live
   * yet) — a REQUIRED key so an unmounted prop fails `tsc` rather than silently reading as
   * "off", which would make Step 1 state a measurement nobody made.
   */
  tick: TickState | null;
  /**
   * ⭐ W24-W1 — WHAT THE RUN IS DOING RIGHT NOW, and it is the honest half of owner item 6
   * ("Run once now is laggy / looks stuck").
   *
   * MEASURED by the session that owns the surface: the engine work item 6 blamed is ~0.9 ms of a
   * ~23 ms request, so there is no performance defect to fix. What IS true is that a
   * `discover_instagram` run legitimately blocks up to `BD_FILTER_WAIT` = 120 s at the vendor,
   * and the engine has been publishing a live step text the whole time that NOTHING has ever
   * rendered — so a two-minute vendor wait and a genuinely hung thread have been pixel-identical.
   * They are not the same thing and the server already knows which one it is.
   *
   * REQUIRED, not optional: an optional prop degrades to "the feature does not exist", which is
   * exactly what shipped for the last four releases and is indistinguishable from never having
   * built it. `""` while nothing is running is a real value and the honest one — a step text left
   * over from a finished run is a worse answer than no answer.
   * ⛔ Filled from `liveStepOf(a)`, never from `automation.liveStep`: there is no such field on
   * the wire (C-TYPES amendment A2) and reading one would compile, type-check and be `undefined`
   * forever.
   */
  liveStep: string;
  /**
   * ⭐ W31-T31 — `fresh` is the definition the WRITE JUST RETURNED, when the caller has one.
   * Given it, the surface corrects that one row; without it, it falls back to a full reload. Every
   * mutation door here already had the response in hand and was discarding it, which is what made
   * a save cost two round trips. Optional on purpose: the doors that genuinely do not have a fresh
   * definition (a delete, a run) still reload, and must.
   */
  onSaved: (id?: string, fresh?: Automation) => void | Promise<void>;
  onDeleted: () => void | Promise<void>;
}

/*
 * ⛔ `STATE_LABELS` IS GONE WITH THE HEADER CHIP IT WORDED (item 15b, C-DETAIL). It mapped the
 * stored run enum to a sentence for `.auto-chip`, and the chip left the title row so that
 * Builder/Board could have that space. The FACT it carried is not lost and was never only here:
 * the builder's own `chip()` says "Last run succeeded / failed / Paused / Not set up / Finish
 * configuration" on the trigger step, the header's Run-now button says "Running…", and the rail
 * carries a status dot per automation. A second wording of the same enum in this file would have
 * been a third place for the three to disagree.
 */

/*
 * ⛔ `CRON_DRIVEN_TRIGGERS = ["schedule", "ig_profile_match"]` STOOD HERE AND IS DELETED
 * (wave 25 — DEBT D-55 CLOSES, both halves in one change or neither).
 *
 * It was this file's ONE client-side copy of a server fact: which triggers the cron drives.
 * Nothing on the wire said it — `TriggerOption` carried `key/label/ready/needs/planned/connect/
 * detail/group` and none of them answer it — so the client named the keys itself, with a comment
 * naming its own replacement.
 *
 * The wire carries it now: `TriggerOption.schedules`, derived server-side from
 * `TRIGGER_SCHEDULE_KEYS` MINUS `manual`. That subtraction is the whole reason the engine could
 * not simply ship its existing set: `manual` is in it only to decide which way the trigger node's
 * SWITCH flips, and a manual trigger drawing a cron face would contradict its own sentence
 * ("nothing else starts it").
 *
 * ⚠ WHY IT WAS WORTH CLOSING, given it failed VISIBLY rather than silently: a new cron-driven
 * trigger would simply show no schedule face — a control missing, with nothing red and nothing
 * to explain it. That is cheaper than corrupt data and more expensive than it looks, because the
 * person who notices is a user, not a gate. D-55's own history is the second lesson: it was
 * booked LATE, after the wave that found it had already shipped, because a worker cannot write
 * the debt register and the ask never reached it.
 */

interface ColumnPlan {
  column: string;
  include: boolean;
  key: string;
}

export default function AutomationDetail({
  automation,
  cronPresets,
  paidReady,
  discover,
  triggers,
  catalog,
  vocab,
  tick,
  liveStep,
  onSaved,
  onDeleted,
}: Props) {
  const cfg = (automation.config || {}) as Record<string, string | number | boolean>;
  const kind = automation.kind;

  /**
   * ⭐⭐ WAVE 30 · W30-T17 — IS THIS A CORPUS SEARCH? ONE PREDICATE, AND THAT IS THE POINT.
   *
   * ⛔ THE DEFECT THIS CLOSES, AND WHY IT WAS A 400 RATHER THAN A MISSING FEATURE.
   * `automation_engine.py:clean_config`'s discovery branch is already SHARED across both kinds
   * (`if kind in ("discover_instagram","discover_tiktok")`) and it reads `recordsLimit` with NO
   * fallback to the stored value — so a config that omits the key is read as `limit = 0` and
   * refused with the owner's exact sentence about an unbounded discovery query. Three arms in
   * this file tested `kind === "discover_instagram"` as a literal, so a `discover_tiktok`
   * automation fell through `buildConfig` to `{ targetTable }` and every save after the first
   * 400'd — on a panel that was showing the person a records limit the whole time.
   *
   * ⛔ A SHARED PREDICATE, NOT A SECOND STRING TEST, AND THE REASON IS MEASURED RATHER THAN
   * STYLISTIC: TikTok's kind shipped in wave 29 and was missed in FOUR places here and THREE in
   * the engine, because each site was an independent literal and none of them was wrong on its
   * own. B is making the same change server-side in the same wave for the same reason. The next
   * platform gets missed once — here — instead of seven times.
   *
   * ⚠ NOT the same question as "does this automation have a TikTok trigger". The kind is what
   * `clean_config` branches on and what `RUNNERS` dispatches on; the trigger is what a person
   * picked. `clean_definition`'s law 1 derives one from the other, server-side, and this client
   * reads the RESULT rather than re-deriving it — two derivations of one fact is how the two
   * halves disagree.
   */
  const isDiscovery = kind === "discover_instagram" || kind === "discover_tiktok";

  const [name, setName] = useState(automation.name || "");
  const [cron, setCron] = useState(automation.schedule?.cron || "0 6 * * *");
  const [message, setMessage] = useState("");
  const [problem, setProblem] = useState("");
  const [saving, setSaving] = useState(false);
  const [touched, setTouched] = useState(false);
  const [triggerBusy, setTriggerBusy] = useState(false);
  const [oauth, setOauth] = useState<OAuthStatus | null>(null);
  /**
   * ⚠ "SHOW ME THE CRON" IS A VIEW CHOICE, NOT A SCHEDULE CHANGE — and forgetting that once
   * made the Custom option DEAD. Everything else on the trigger face is derived from the
   * stored cron, so picking "Custom (cron)" wrote… the same cron, which `readCron` read back
   * as "every Wednesday" and the select snapped straight back. It lives HERE, above the two
   * builder controls does not lose it.
   */
  const [showCron, setShowCron] = useState(false);

  // --- scrape_db half
  const [url, setUrl] = useState(String(cfg.url || ""));
  const [extract, setExtract] = useState(String(cfg.extract || "table"));
  const [tableIndex, setTableIndex] = useState(Number(cfg.tableIndex || 0));
  const [targetLabel, setTargetLabel] = useState(String(cfg.targetLabel || ""));
  const [preview, setPreview] = useState<SourcePreview | null>(null);
  const [reading, setReading] = useState(false);
  const [plan, setPlan] = useState<ColumnPlan[]>(() =>
    Object.entries((automation.config as { fieldMap?: Record<string, string> })?.fieldMap || {})
      .map(([column, key]) => ({ column, include: true, key }))
  );
  const [keyField, setKeyField] = useState(String(cfg.keyField || ""));

  // --- field_instagram half
  const [tables, setTables] = useState<UserTable[]>([]);
  const [targetTable, setTargetTable] = useState(String(cfg.targetTable || ""));
  const [fieldKey, setFieldKey] = useState(String(cfg.fieldKey || ""));
  const [urlField, setUrlField] = useState(String(cfg.urlField || ""));
  /* ⭐ WAVE 26 · ITEM 7 / C5 — the seed is `DEFAULT_POSTS_PER_PULL` (10), and it is CLAMPED.
     ⛔ TWO SEPARATE WAYS THIS PANEL COULD SEND A VALUE THE SAVE DOOR REFUSES, and fixing only the
     first would have looked complete:
       1. NO stored value -> the fallback. It was 24, which is now ABOVE the ceiling, so a fresh
          automation seeded a number its own save door rejects — [[default-must-pass-its-own-guard]]
          exactly: a law added later turns yesterday's safe default into a value the product
          refuses, and nothing goes red because the default predates the law.
       2. A STORED value of 24 — which is EVERY `field_instagram` automation written before this
          wave (A-2 says so in as many words). `buildConfig`'s arm always includes `maxPosts`, so
          `clean_config` sees `sent=True` and `clean_max_posts` REFUSES rather than clamps: opening
          a legacy automation and pressing Save produced a 400 naming a cap the user never chose
          and could not see. The panel bounding its INPUT does not help — the input was never
          touched.
     ⚠ A-2 closed the inherited case on the server for the ACTION path (`_clean_action_config` uses
     `submitted=False`, which clamps). It could not close it here, because `submitted` is the
     server asking "did a person type this?" and only the CLIENT knows — so on this path the clamp
     is the panel's job. Displaying what it will send is the whole fix. */
  const [maxPosts, setMaxPosts] = useState(
    Math.min(Math.max(1, Number(cfg.maxPosts) || 10), 12)
  );

  // --- the DISCOVERY half (R7) — `discover_instagram` and, since W30-T17, `discover_tiktok`.
  //     ⚠ The 25 is not this panel's invention: it mirrors `automation_engine.py`'s own
  //     `DISCOVER_SEED_RECORDS`, which is what the server writes when a discovery trigger is
  //     first picked. Two numbers here would be two answers to "how many by default".
  const [recordsLimit, setRecordsLimit] = useState(Number(cfg.recordsLimit || 25));
  const [joinOp, setJoinOp] = useState(String(cfg.operator || "and"));
  /**
   * ⛔ NO SEEDED CONDITION, AND THAT IS THE FIX.
   *
   * This used to seed `{followers >= 10000}` when nothing was stored — a field the narrowing
   * guard does not count and a comparison it does not count either. Every Save of a discovery
   * automation therefore sent it and 400'd with "add at least one CONTENT condition…": renaming
   * the automation, changing the cron, editing a step, all of it, on an automation whose filter
   * the user had never touched. Wave 24 made it unmissable by deleting the wizard, so this seed
   * became what every new Instagram automation carries from birth.
   *
   * ⚠ EMPTY IS LEGAL AND MEANS SOMETHING. `clean_predicates` stores `[]` inert (wave 24 A2) and
   * `configured: false` rides the wire, so the surface says "finish setting this up" instead of
   * refusing the save — while `run_discover_instagram` still refuses to SPEND on a filter that
   * narrows nothing. Incomplete is a state; only the money door is a wall.
   */
  const [preds, setPreds] = useState<Predicate[]>(() => {
    const stored = (automation.config as { predicates?: Predicate[] })?.predicates;
    return stored && stored.length ? stored.map((p) => ({ ...p })) : [];
  });
  const [estimate, setEstimate] = useState<DiscoverEstimate | null>(null);

  // --- run history drill
  const [drill, setDrill] = useState<RunRows | null>(null);
  const [drillFor, setDrillFor] = useState("");

  /**
   * ⭐ WAVE 27 · OWNER ITEM 33 / CONTRACT C11 — WHICH PANEL OWNS THE RIGHT-HAND COLUMN.
   *
   * ⛔ THE STATE IS HERE BECAUSE THE TWO PANELS ARE NOT SIBLINGS IN ONE FILE. Properties is
   * rendered by `AutomationBuilder`; the Run log is rendered below by this component. Both are
   * `.auto-panel` children of `.auto-work`, so they were drawn SIDE BY SIDE — two 380px columns
   * on a surface whose actual content is the flow. The reference (`reference/Airtable
   * Automation 11.png`) shows one dismissable panel, and the only component that can guarantee
   * "exactly one" is the parent of both.
   *
   * ⚠ PROPERTIES IS THE DEFAULT, not the log. Opening an automation to read last night's run is
   * the rarer errand; opening it to change something is the common one, and the panel that
   * appears should be the one a click on a step is about to fill.
   */
  const [aside, setAside] = useState<"properties" | "runs">("properties");
  /**
   * The switch, drawn in BOTH panel heads so it does not move when the panel does.
   *
   * ⛔ A RADIOGROUP, not two buttons that happen to look joined: they are two values of one
   * setting, and `aria-pressed` on each would tell a screen reader about two independent
   * toggles that can both be off — a state this control cannot express.
   */
  const panelTabs = (
    <div className="auto-paneltabs" role="radiogroup" aria-label="Right-hand panel">
      {([["properties", "Properties"], ["runs", "Run history"]] as const).map(([k, label]) => (
        <button
          key={k}
          type="button"
          role="radio"
          aria-checked={aside === k}
          className={"auto-paneltab" + (aside === k ? " is-on" : "")}
          onClick={() => setAside(k)}
        >
          {label}
        </button>
      ))}
    </div>
  );

  useEffect(() => {
    const ac = new AbortController();
    listTables(ac.signal)
      .then((r) => setTables(r.tables))
      .catch(() => setTables([]));
    return () => ac.abort();
  }, []);

  /**
   * ⭐ WAVE 26 · ITEM 9 / R11 — RE-READ THE DATABASES after one is created from inside an action's
   * picker. The create route answers `{key}` only, so the new row's label, `rowCount` and fields
   * come from here rather than being invented client-side — a spliced-in row would carry three
   * guessed facts, and the picker renders two of them.
   */
  const reloadTables = () =>
    listTables()
      .then((r) => setTables(r.tables))
      .catch(() => undefined);

  useEffect(() => {
    const ac = new AbortController();
    oauthStatus(ac.signal)
      .then(setOauth)
      // Unconfigured OAuth is FAIL-CLOSED, not an error (C5): no status means no
      // connection, which the trigger face already states as "not set up yet".
      .catch(() => setOauth(null));
    return () => ac.abort();
  }, []);

  const table = tables.find((t) => t.key === targetTable) || null;

  /** ITEM 22 / D-70 / R12 — may Run be pressed, and what does it say if not. One definition,
   *  shared with the Builder's own Run button (`runBlock`). */
  const runState = runBlock(automation);

  /**
   * ⭐ THE WALKING RECORD'S DATABASE — the root cause of "the condition on the group names no
   * field" (owner item 12a), and it was never a condition bug.
   *
   * `AutomationBuilder` resolved the field list from `trigger.table` ALONE. Every trigger without
   * a table — manual, schedule, and the new `ig_profile_match`, which watches nothing because it
   * MAKES rows — therefore handed `CondBuilder` an empty list, so the field picker offered
   * "Choose a field…" and nothing else, and any condition saved against it named no field. The
   * picker looked broken; the automation simply had no table for it to read.
   *
   * ⛔ MIRRORS `automation_engine._flow_table` (`:4319`) RATHER THAN INVENTING A SECOND RULE, and
   * the ORDER is the part worth copying: the automation's own `targetTable` WINS over the
   * trigger's table, not the other way round. A flow whose actions walk one database while its
   * trigger watches another is a real shape, and getting the precedence backwards would offer the
   * columns of the database the actions do not touch — a wrong list that looks authoritative,
   * which this file already says is worse than no list.
   * ⚠ The trigger's OWN pickers keep using the trigger's own table; these are two different
   * questions and answering both from one variable is what produced the bug.
   */
  /*
    ⭐ W30-T17 — BOTH DISCOVERY KINDS TAKE THIS BRANCH NOW, AND THE FALLBACK DELIBERATELY DOES NOT.
    ⛔ `discover.table` IS THE INSTAGRAM VOCABULARY'S OWN TABLE, not "the discovery table".
    MEASURED (scout, 2026-08-12): the wire carries exactly ONE `discover` object — built by
    `routes_automation.py:list_automations` from `automation_engine.py:BD_FILTER_FIELDS` and
    friends, with no platform argument anywhere on the route — and its `table` is Instagram's.
    Widening this fallback to every discovery kind would therefore have offered a TikTok
    automation the columns of an Instagram database: a list that looks authoritative and names
    the wrong database, which is the exact failure the comment above says is worse than no list.
    So TikTok gets `cfg.targetTable` (which `clean_config` sets to its own `ut_tt_profile`) or
    NOTHING, and nothing renders as "this automation has no table for it to read" — true.
  */
  const walkTable = isDiscovery
    ? String(cfg.targetTable || "") ||
      (kind === "discover_instagram" ? String(discover?.table || "") : "")
    : String(cfg.targetTable || "") || String(automation.trigger?.table || "");

  /**
   * WHAT STARTS THIS ONE, as a key. The server's stored trigger when it ships one;
   * otherwise derived from the only two shapes a definition can express today.
   * Derived rather than defaulted: `schedule.enabled` IS the answer on today's
   * engine, so this is a reading of stored state, not a guess about it.
   */
  const triggerKey =
    automation.trigger?.key || (automation.schedule?.enabled ? "schedule" : "manual");

  /**
   * ⭐ WAVE 25 — D-55's OTHER HALF: does the cron drive THIS trigger? The SERVER's answer.
   *
   * ⛔ `=== true`, NOT TRUTHY, and absent reads as NO. A server older than this client sends no
   * `schedules` at all, and the fail-closed direction is to draw FEWER controls: a missing
   * schedule face is a control the reader can go and find, while a cron face on a manual trigger
   * is the surface contradicting its own sentence. `??  CRON_DRIVEN_TRIGGERS.includes(...)` was
   * the tempting shape and it is exactly the copy this debt row closes — a fallback list is a
   * list, and it goes stale on the same schedule the original did.
   *
   * ⚠ AN UNKNOWN KEY GETS `false` for the same reason: `triggers` may not carry the stored key at
   * all (a trigger this deployment withholds), and inventing a cron face for one nobody offered
   * would be the client answering a question the server declined.
   */
  const cronDriven = (triggers || []).find((t) => t.key === triggerKey)?.schedules === true;

  const edit = <T,>(set: (v: T) => void) => (v: T) => {
    setTouched(true);
    set(v);
  };

  const readPage = useCallback(async () => {
    setReading(true);
    setProblem("");
    try {
      const p = await previewSource(url, extract, tableIndex);
      setPreview(p);
      if (!p.ok) {
        setProblem(p.note || `That page answered ${p.status}.`);
        return;
      }
      // Seed the plan from what the page ACTUALLY offers, keeping any mapping the
      // user already made for a column that is still there — re-reading a page
      // must not throw away the work of mapping it.
      setPlan((prev) => {
        const had = new Map(prev.map((c) => [c.column, c]));
        return p.columns.map((column) => {
          const before = had.get(column);
          return {
            column,
            include: before ? before.include : true,
            key: before ? before.key : fieldKeyFor(column),
          };
        });
      });
      setKeyField((k) =>
        k && p.columns.some((c) => fieldKeyFor(c) === k) ? k : fieldKeyFor(p.columns[0] || "")
      );
      if (!targetLabel) setTargetLabel(p.title || "Scraped table");
      setTouched(true);
      setMessage(
        `Read ${p.rowCount} rows and ${p.columns.length} columns` +
          (p.tableCount && p.tableCount > 1 ? ` (of ${p.tableCount} tables on the page)` : "")
      );
    } catch (e) {
      setProblem(e instanceof AutomationError ? e.message : "That page could not be read.");
    } finally {
      setReading(false);
    }
  }, [url, extract, tableIndex, targetLabel]);

  /**
   * ⚠ The switch flags (`tier`, `noFallback`, `dryRun`) are DELIBERATELY absent.
   * They are node switches, not panel fields, and the server keeps the stored
   * value for any flag a config omits — so a Save from here can never quietly
   * turn the paid rung off. (`clean_config`'s `flag()`, asserted in section N.)
   */
  /**
   * The discovery conditions that are FINISHED enough to send.
   *
   * A condition is written in two gestures — pick the comparison, then type the value — and the
   * server refuses a non-nullary comparison with no value ("give a value to compare 'biography'
   * against"). Sending a half-written row therefore turned every Save between those two gestures
   * into an error about a condition the user was still in the middle of writing. Held back
   * instead: the row stays on screen and in state, it just is not claimed to the server yet.
   *
   * ⚠ HELD BACK IS SAID OUT LOUD (see `persist`) — a row that vanishes from the stored filter
   * without a word is the silent-drop class this module refuses everywhere else.
   *
   * The nullary list comes off the wire; the literal is only for a server that did not send one,
   * and it errs toward SENDING (the server then refuses, loudly) rather than toward dropping.
   */
  const completePreds = (): Predicate[] => {
    const nullary = discover?.nullaryOperators || ["is_null", "is_not_null"];
    return preds.filter((p) => {
      if (!p.name || !p.operator) return false;
      if (nullary.includes(p.operator)) return true;
      return !(
        p.value === undefined ||
        p.value === null ||
        (typeof p.value === "string" && !p.value.trim())
      );
    });
  };

  /**
   * The sentence for whatever `completePreds` held back, or "" when it held nothing.
   *
   * ⚠ EVERY SAVE PATH SAYS IT, not just the Save button. The trigger picker and the cron control
   * persist IMMEDIATELY (R9) and announce nothing on success — so without this, CLEARING a
   * condition's value and then changing the trigger would drop that condition from the stored
   * filter with the row still on screen and not a word anywhere. Holding a half-written row back
   * is a courtesy; holding it back in silence is the drop class this module refuses everywhere.
   */
  /**
   * ⭐ WAVE 27 (item 11) — the COUNT, split out of the sentence below.
   *
   * `start()` has to say the same fact in a different sentence ("it is running without it"),
   * and the alternative was for it to read `heldNote()`'s prose and sniff it for a plural —
   * a second reader of a string that exists to be shown to a person. One arithmetic, two
   * wordings, and neither wording is parsed by anything.
   */
  /* W30-T17: both discovery kinds hold half-written rows back, because `completePreds` is what
     `buildConfig` sends for both and a held row that is never announced is the silent-drop class
     this module refuses everywhere else. A TikTok automation used to report 0 held, always. */
  const heldCount = (): number =>
    isDiscovery ? preds.length - completePreds().length : 0;

  const heldNote = (): string => {
    const held = heldCount();
    if (held < 1) return "";
    return `Saved — ${held} condition${held === 1 ? " was" : "s were"} not saved yet, `
      + `${held === 1 ? "it needs" : "they need"} a value.`;
  };

  const buildConfig = (): Record<string, unknown> => {
    if (kind === "scrape_db") {
      const fieldMap: Record<string, string> = {};
      for (const c of plan) if (c.include && c.key) fieldMap[c.column] = c.key;
      return {
        url,
        extract,
        tableIndex,
        fieldMap,
        keyField,
        targetTable: String(cfg.targetTable || ""),
        targetLabel,
      };
    }
    /*
      ⛔ BOTH DISCOVERY KINDS, ONE SHAPE (W30-T17). This arm was `kind === "discover_instagram"`,
      so a TikTok discovery automation fell all the way through to `{ targetTable }` and dropped
      `recordsLimit` on EVERY save. The server's discovery branch reads that key with no
      prev-fallback, so the drop is not "the old value survives" — it is `limit = 0`, which the
      save door refuses outright. Instagram never showed it because this arm existed.
      ⚠ The FIRST save is a different question and it is not fixed here: `pickTrigger` calls this
      while `kind` is still `plain` (the server flips it after reading the trigger), so the
      opening PATCH of a brand-new discovery automation carries no limit by construction. That
      one is the engine's seed to set — W30-T04 — and it is why the two tickets are separate.
    */
    if (isDiscovery) {
      return {
        recordsLimit,
        operator: joinOp,
        predicates: completePreds(),
        targetTable: String(cfg.targetTable || ""),
      };
    }
    if (kind === "field_instagram") {
      return { targetTable, fieldKey, urlField, maxPosts };
    }
    /*
      ⛔ EVERY ARM IS EXPLICIT AND THE FALL-THROUGH IS THE MINIMUM (wave 24, R6).
      This used to END at `field_instagram`'s shape, as a bare `return` — so it was not a default,
      it was a SILENT DEFAULT KIND, and R6 made `plain` the kind of every new automation. Picking
      a trigger (the first thing anyone does now) called this and sent
      `{targetTable, fieldKey, urlField, maxPosts}` for an automation that has no Instagram column
      and no posts. It happens to be harmless today — `clean_config`'s `plain` arm reads only
      `targetTable` and `lanes`, and falls back to the stored `lanes` — so nothing is lost and
      nothing 400s. It is fixed anyway, because "harmless" here is luck rather than design: the
      keys are dropped by a validator that was written independently, and the day `plain` grows a
      key this shape happens to contain, a Save from any panel would write a value nobody chose.

      This is the same defect C found twice in the engine on the same day — `graph()`'s final
      `else` is `scrape_db`'s branch and `compose_sentence`'s is `discover_instagram`'s — so a new
      kind silently inherited another kind's whole description. A last bare `else` is not a
      default; it is whichever arm happened to be written last.

      `targetTable` is the one key EVERY kind shares (it is what `_flow_table` resolves), so the
      remaining fall-through carries that and nothing else.
    */
    return { targetTable };
  };

  /**
   * ⚠ W31-T31 — RETURNS THE FRESH AUTOMATION, not just its id. The PATCH response already carries
   * the whole updated definition; handing it to `onSaved` is what lets the surface correct one row
   * instead of re-reading the entire list to learn what it was just told.
   */
  const persist = async (announce = true, sched?: { cron: string; enabled: boolean }) => {
    const res = await patchAutomation(automation.id, {
      name,
      kind,
      config: buildConfig(),
      schedule: sched || { cron, enabled: !!automation.schedule?.enabled },
    });
    setTouched(false);
    if (announce) setMessage(heldNote() || "Saved.");
    return res.automation;
  };

  /**
   * Step 1's controls persist IMMEDIATELY (R9). A trigger you have to remember to Save is the
   * same defect as a switch you have to remember to Save — the node switches next to it are
   * immediate, and two save disciplines in one column is how a user ends up with an automation
   * that says "every Monday" and runs every day.
   *
   * ⚠ The CUSTOM CRON STRING is the exception, and deliberately: it is free text, so persisting
   * per keystroke would PATCH a half-typed schedule five times. It marks the editor touched and
   * rides the header's Save — and because every immediate write goes through `persist()`, an
   * unsaved cron edit is written with it rather than being discarded by the reload.
   */
  const saveTrigger = async (next: { cron: string; enabled: boolean }) => {
    setTriggerBusy(true);
    setProblem("");
    setMessage("");
    setCron(next.cron);
    try {
      const { automation: fresh } = await patchAutomation(automation.id, {
        name,
        kind,
        config: buildConfig(),
        schedule: { cron: next.cron, enabled: next.enabled },
      });
      setTouched(false);
      setMessage(heldNote());
      await onSaved(fresh.id, fresh);          // W31-T31: the response, not a second read
    } catch (e) {
      setProblem(e instanceof AutomationError ? e.message : "That schedule was not saved.");
    } finally {
      setTriggerBusy(false);
    }
  };

  /**
   * Pick WHAT STARTS this automation (C3).
   *
   * ⚠ TWO WRITES IN ONE PATCH, on purpose. `trigger: {key}` is the shape this wave
   * introduces; `schedule.enabled` is what today's engine actually reads, and the
   * two must agree or a picker that says "manual" would leave a live cron behind
   * it. The schedule half is derived from the key rather than remembered — the
   * trigger IS the on/off, which is the one-control-per-fact rule this file's
   * header states.
   *
   * ⚠ ASSUMPTION, STATED: the engine session confirms the write shape in the wave
   * mailbox. An engine that ignores an unknown `trigger` key still gets the
   * schedule half right, so the fallback is correct rather than merely harmless.
   */
  const pickTrigger = async (key: string) => {
    setTriggerBusy(true);
    setProblem("");
    setMessage("");
    if (key !== "schedule") setShowCron(false);
    try {
      const { automation: fresh } = await patchAutomation(automation.id, {
        name,
        kind,
        config: buildConfig(),
        trigger: { key },
        schedule: { cron, enabled: key === "schedule" },
      });
      setTouched(false);
      setMessage(heldNote());
      await onSaved(fresh.id, fresh);          // W31-T31: the response, not a second read
    } catch (e) {
      setProblem(e instanceof AutomationError ? e.message : "That trigger was not saved.");
    } finally {
      setTriggerBusy(false);
    }
  };

  /**
   * Move ONE record along the flow — R1's promotion.
   * ⚠ ITEM 15 / R6: a clause here used to equate this with the discovery flow's old checkbox
   * column, which the owner deleted this wave. A move writes the record's STAGE, and the stages
   * are the ones the USER defined — the automation has no opinion about what happens to a
   * candidate after it is found.
   * ⛔ The dead column's NAME is deliberately not restated anywhere in this module: the gate's
   * rule is that the token does not appear at all, which is the only version of this check that
   * cannot be satisfied by a comment merely claiming the deletion happened.
   *
   * ⛔ THE SERVER DECIDES, and its refusal is printed VERBATIM. This posts the drop
   * it was given; a client that pre-judged legality would be a second copy of a
   * permission rule, and the two copies disagreeing leaves a card that will not
   * move for a reason nobody can read.
   */
  /**
   * ⭐⭐ WAVE 31 · T31 — "SAVING…" IS THE LIFETIME OF THE WRITE, AND NOTHING ELSE.
   *
   * ⛔ THE DEFECT, in the owner's words: *"It just say Saving... and takes a long time for me to
   * change options and configs etc."* This function used to hold `saving` across `persist()` AND
   * `onSaved()` — and `onSaved` reloads the whole list. So the label a person reads as "your edit is
   * being written" was in fact reporting a write plus an unrelated full `GET /automations`, and the
   * server-side half of that read is the one W31-T30 measured at seconds. Two round trips wearing
   * one word.
   *
   * ⚠ AND THE SPLIT IS A CORRECTNESS FIX, NOT ONLY A SPEED ONE. The old `try` wrapped both calls in
   * one `catch` that printed *"That could not be saved."* — so a save that SUCCEEDED and whose list
   * refresh then failed told the person their work was lost while it sat safely in the store. The
   * inverse of the bug being fixed, and the worse of the two. The write's failure is reported; the
   * refresh's failure is not allowed to speak for it.
   */
  const save = async () => {
    setSaving(true);
    setProblem("");
    setMessage("");
    let fresh: Automation | undefined;
    try {
      fresh = await persist();
    } catch (e) {
      setProblem(e instanceof AutomationError ? e.message : "That could not be saved.");
      return;
    } finally {
      // ⭐ HERE, not after `onSaved`. This is the whole ticket.
      setSaving(false);
    }
    try {
      await onSaved(fresh.id, fresh);
    } catch {
      /* The save is already committed — see the note above. A failed refresh is not a failed save. */
    }
  };

  /**
   * A node switch is immediate — a switch that needs a separate Save is not a
   * switch. Unsaved panel edits are persisted FIRST rather than being discarded
   * by the reload that follows, which is the one way this could lose work.
   */
  const flip = async (nodeId: string) => {
    setProblem("");
    setMessage("");
    try {
      if (touched) await persist(false);
      // ⭐ W31-T31 — the toggle RESPONSE is the new definition. Passing it on means a node switch
      // costs the toggle itself, where it used to cost a toggle plus a whole-list read (plus a
      // PATCH when the panel was dirty — three round trips for one click).
      const { automation: fresh } = await toggleNode(automation.id, nodeId);
      await onSaved(fresh.id, fresh);
    } catch (e) {
      setProblem(e instanceof AutomationError ? e.message : "That step was not changed.");
    }
  };

  /**
   * ⭐ WAVE 27 · OWNER ITEM 11 — **RUN NOW WRITES WHAT IS ON SCREEN.**
   *
   * ⛔ THE DEFECT, in the owner's words: *"I have to save twice"*. Every trigger-side control
   * this panel owns — the discovery conditions (`preds`), the record limit, the and/or join,
   * `maxPosts` — lives in LOCAL STATE until something calls `persist()`. `start()` called
   * `runAutomation` directly, so pressing Run after editing a condition ran the automation
   * against the config as it was BEFORE the edit, and the only tell was that pressing Save
   * first made it behave. Two presses looked like a fussy UI; it was a run against stale
   * config, and on a `discover_instagram` automation that is a PAID search of the wrong filter.
   *
   * ⚠ THE FIX IS `flip()`'s, VERBATIM (`:606`), and deliberately not a new discipline: a node
   * switch already persisted unsaved panel edits before toggling, for exactly this reason, and
   * the two buttons sit in the same header. `if (touched)` and not an unconditional PATCH —
   * an untouched panel writing its config back would re-send `buildConfig()` on every Run,
   * which is how a clamp or a validator default silently becomes a value the user never chose
   * ([[default-must-pass-its-own-guard]]).
   *
   * ⚠ A FAILED FLUSH MUST NOT RUN. Both calls share one `try`, so a PATCH that 400s leaves the
   * server's sentence on screen and `runAutomation` unreached — running anyway would spend
   * money on precisely the config the server just refused.
   *
   * ⛔ AND THE HELD-BACK SENTENCE IS SAID HERE TOO. `persist(false)` suppresses `heldNote()`
   * because `flip()` had nothing to add to it; this path does. `completePreds()` withholds a
   * condition that has a comparison and no value yet, so a Run pressed mid-edit legitimately
   * searches WITHOUT it — silence there would be the module's own drop class (see `heldNote`),
   * and it is worse on this button than on any other because this one is the one that bills.
   */
  const start = async () => {
    setProblem("");
    try {
      if (touched) await persist(false);
      await runAutomation(automation.id);
      const held = heldCount();
      setMessage(
        held < 1
          ? "Started. The node dots follow it."
          : `Started — running without ${held} condition${held === 1 ? "" : "s"} that still `
            + `${held === 1 ? "needs" : "need"} a value.`
      );
      await onSaved(automation.id);
    } catch (e) {
      setProblem(e instanceof AutomationError ? e.message : "It did not start.");
    }
  };

  const remove = async () => {
    setProblem("");
    try {
      await deleteAutomation(automation.id);
      await onDeleted();
    } catch (e) {
      setProblem(e instanceof AutomationError ? e.message : "It was not deleted.");
    }
  };

  const openDrill = async (entry: RunEntry) => {
    if (drillFor === entry.ts) {
      setDrill(null);
      setDrillFor("");
      return;
    }
    try {
      const rows = await runRows(automation.id);
      setDrill(rows);
      setDrillFor(entry.ts);
    } catch (e) {
      setProblem(e instanceof AutomationError ? e.message : "Those rows could not be read.");
    }
  };

  /**
   * The SCHEDULE half of the trigger face, built once and rendered by the Builder's Properties
   * panel. `AutomationTrigger` owns the vocabulary (C3), the cron round-trip and the tick
   * honesty note; this file owns only what a change should WRITE.
   *
   * ⛔ ONE FACE NOW, NOT TWO (item 10). There used to be a second copy with its picker SHOWN,
   * rendered by the board's `.autob-triggerbar`. The board is for reading where records ARE —
   * the trigger belongs to the Builder — so the bar is gone and with it the only caller that
   * ever wanted the picker from this file. `hidePicker` survives because it is what makes ONE
   * cron round-trip serve the Properties panel without a second trigger dropdown above it.
   */
  const scheduleFace = (
    <AutomationTrigger
      triggers={triggers}
      current={triggerKey}
      cron={cron}
      enabled={!!automation.schedule?.enabled}
      tick={tick}
      nextRunAt={automation.nextRunAt}
      cronPresets={cronPresets}
      busy={triggerBusy}
      onPick={(key) => void pickTrigger(key)}
      onSchedule={(next) => void saveTrigger(next)}
      onCronText={(next) => edit(setCron)(next)}
      oauth={oauth}
      showCron={showCron}
      onShowCron={setShowCron}
      hidePicker
      schedules={cronDriven}
    />
  );

  /**
   * The Builder's writer (C4). It PATCHES a PARTIAL definition — `{trigger}` or `{flow}` — and
   * nothing else, which is what makes an immediate write safe here: `clean_trigger` and
   * `clean_flow` merge against what is stored, so sending the one key that changed cannot
   * blank the rest. (`persist()` above is the whole-form save the machine-step panels use; the
   * two must not be confused, because that one DOES rewrite `config` from local state.)
   */
  const writeDefinition = async (body: Record<string, unknown>) => {
    setTriggerBusy(true);
    setProblem("");
    setMessage("");
    try {
      const { automation: fresh } = await patchAutomation(automation.id, body);
      await onSaved(fresh.id, fresh);          // W31-T31: the response, not a second read
    } catch (e) {
      // ⛔ THE SERVER'S SENTENCE, VERBATIM. `clean_flow` refuses an empty group, an unready
      // action kind, a valueless compare — each with a reason worth reading. A client that
      // predicted those refusals would be a second validator free to disagree with the one
      // that decides.
      setProblem(e instanceof AutomationError ? e.message : "That change was not saved.");
    } finally {
      setTriggerBusy(false);
    }
  };

  /**
   * THE NAME, now that it lives at the top of Properties instead of in the page header
   * (item 15b, C-DETAIL).
   *
   * ⛔ IT COMMITS ON BLUR AND WRITES IMMEDIATELY, like every other free-text field in that
   * panel (the Gmail query, the review prompt, a value). That is not a style choice: the panel
   * stamps "All changes saved", which is its receipt for the immediate-write discipline — a
   * name sitting unsaved underneath it would make the stamp a lie. It is the cron field's exact
   * shape (controlled while typing, one write on blur), because per-keystroke would PATCH a
   * half-typed name.
   *
   * ⚠ A BLANK NAME IS NOT A NAME, and the header's Save has always refused one
   * (`disabled={… || !name.trim()}`). Blurring an emptied box therefore RESTORES the stored
   * name rather than writing an empty string or leaving the box contradicting the store — the
   * two states a bare early-return would have left behind.
   */
  const commitName = async () => {
    const next = name.trim();
    const stored = automation.name || "";
    if (!next) {
      setName(stored);
      return;
    }
    // Whitespace-only edits still normalise the box; they just have nothing to write.
    if (next === stored) {
      if (next !== name) setName(next);
      return;
    }
    setName(next);
    // ⚠ `{name}` ALONE IS A LEGAL PATCH — `engine.patch` merges by key over the stored
    // definition (`automation_engine.py:1176` lists `name` among the patchable six), so this
    // cannot blank the config, the schedule, the trigger or the flow.
    await writeDefinition({ name: next });
  };

  /**
   * THE MACHINE STEPS' CONFIG BODIES, joined on `panel` (C1 amendment 1) and rendered by
   * BOTH views.
   *
   * ⚠ EXTRACTED, NOT COPIED, and the difference is the whole point. The Builder shows the
   * same engine-derived steps the Board's process strip does, so a second copy of these
   * five panels would be two surfaces that must agree with `clean_config` forever — and the
   * way they would stop agreeing is silent: one of them keeps writing a key the engine
   * renamed. One body, two callers.
   */
  const panelBody = (panelKey: string) => (
    <>
                {panelKey === "source" && kind === "scrape_db" ? (
                  <>
                    <div className="auto-field">
                      <label htmlFor="auto-url">Page URL</label>
                      <input
                        id="auto-url"
                        className="auto-input"
                        value={url}
                        placeholder="https://en.wikipedia.org/wiki/List_of_S%26P_500_companies"
                        onChange={(e) => edit(setUrl)(e.target.value)}
                      />
                    </div>
                    <button
                      type="button"
                      className="auto-btn"
                      disabled={!url.trim() || reading}
                      onClick={() => void readPage()}
                    >
                      {reading ? "Reading…" : "Read the page"}
                    </button>
                    <div className="auto-field">
                      <label htmlFor="auto-extract">Read</label>
                      <select
                        id="auto-extract"
                        className="auto-input"
                        value={extract}
                        onChange={(e) => edit(setExtract)(e.target.value)}
                      >
                        <option value="table">An HTML table</option>
                        <option value="jsonld">Structured data (JSON-LD)</option>
                      </select>
                    </div>
                    {extract === "table" ? (
                      <div className="auto-field">
                        <label htmlFor="auto-tidx">Which table</label>
                        <input
                          id="auto-tidx"
                          className="auto-input"
                          type="number"
                          min={0}
                          value={tableIndex}
                          onChange={(e) => edit(setTableIndex)(Number(e.target.value) || 0)}
                        />
                      </div>
                    ) : null}
                    {/* "loopback and link-local addresses" is the SSRF rail describing itself.
                        The rail still refuses them, loudly, at the moment it happens. */}
                    <p className="auto-hint">Public web pages only.</p>
                  </>
                ) : null}

                {panelKey === "columns" ? (
                  plan.length ? (
                    <>
                      {/* The upsert rules, in full, to somebody choosing a column. What they
                          need is which column, and that rows are updated rather than duplicated. */}
                      <p className="auto-hint">
                        Pick the column that identifies a row, so re-runs update it instead of
                        adding it again.
                      </p>
                      <div className="auto-panel-scroll">
                        <table className="auto-table">
                          <thead>
                            <tr>
                              <th>Use</th>
                              <th>Source column</th>
                              <th>Field key</th>
                              <th>Key</th>
                            </tr>
                          </thead>
                          <tbody>
                            {plan.map((c, i) => (
                              <tr key={c.column}>
                                <td>
                                  <input
                                    type="checkbox"
                                    checked={c.include}
                                    aria-label={`Include ${c.column}`}
                                    onChange={(e) =>
                                      edit(setPlan)(
                                        plan.map((x, j) =>
                                          j === i ? { ...x, include: e.target.checked } : x
                                        )
                                      )
                                    }
                                  />
                                </td>
                                <td className="auto-cell-name">{c.column}</td>
                                <td>
                                  <input
                                    className="auto-input is-small"
                                    value={c.key}
                                    aria-label={`Field key for ${c.column}`}
                                    onChange={(e) =>
                                      edit(setPlan)(
                                        plan.map((x, j) =>
                                          j === i
                                            ? { ...x, key: fieldKeyFor(e.target.value) }
                                            : x
                                        )
                                      )
                                    }
                                  />
                                </td>
                                <td>
                                  <input
                                    type="radio"
                                    name="auto-keyfield"
                                    checked={keyField === c.key && c.include}
                                    disabled={!c.include}
                                    aria-label={`Use ${c.column} as the key`}
                                    onChange={() => edit(setKeyField)(c.key)}
                                  />
                                </td>
                              </tr>
                            ))}
                          </tbody>
                        </table>
                      </div>
                      {preview?.sample?.length ? (
                        <p className="auto-hint">
                          First row read: {Object.values(preview.sample[0]).slice(0, 4).join(" · ")}
                        </p>
                      ) : null}
                    </>
                  ) : (
                    <p className="auto-note">
                      Open the Fetch step and read the page to see what columns it offers.
                    </p>
                  )
                ) : null}

                {panelKey === "source" && kind === "field_instagram" ? (
                  <>
                    <div className="auto-field">
                      <label htmlFor="auto-table">Database</label>
                      <select
                        id="auto-table"
                        className="auto-input"
                        value={targetTable}
                        onChange={(e) => {
                          edit(setTargetTable)(e.target.value);
                          setFieldKey("");
                          setUrlField("");
                        }}
                      >
                        <option value="">Choose a database…</option>
                        {targetTable && !tables.some((t) => t.key === targetTable) ? (
                          <option value={targetTable}>{targetTable} (not visible to you)</option>
                        ) : null}
                        {tables.map((t) => (
                          <option key={t.key} value={t.key}>
                            {t.label} ({t.rowCount} {t.rowCount === 1 ? "row" : "rows"})
                          </option>
                        ))}
                      </select>
                    </div>
                    <div className="auto-field">
                      <label htmlFor="auto-urlfield">Profile URL column</label>
                      <select
                        id="auto-urlfield"
                        className="auto-input"
                        value={urlField}
                        onChange={(e) => edit(setUrlField)(e.target.value)}
                      >
                        <option value="">The column the field is bound to</option>
                        {(table?.fields || []).map((f) => (
                          <option key={f.key} value={f.key}>
                            {f.label}
                          </option>
                        ))}
                      </select>
                    </div>
                    <div className="auto-field">
                      <label htmlFor="auto-fieldkey">Automation column</label>
                      <select
                        id="auto-fieldkey"
                        className="auto-input"
                        value={fieldKey}
                        onChange={(e) => edit(setFieldKey)(e.target.value)}
                      >
                        <option value="">Choose a column…</option>
                        {/*
                          ⚠ THE STORED VALUE IS ALWAYS AN OPTION, even when the list
                          below does not contain it. A <select> whose `value` matches
                          no <option> renders the FIRST one — so a column the server
                          knows about but this list has not caught up with would look
                          like a DIFFERENT column, and the next Save would write that
                          different column without anybody choosing it. Showing it as
                          "(not in this database)" is the honest version.
                        */}
                        {fieldKey && !(table?.fields || []).some((f) => f.key === fieldKey) ? (
                          <option value={fieldKey}>{fieldKey} (not in this database)</option>
                        ) : null}
                        {(table?.fields || [])
                          .filter((f) => f.type === "automation")
                          .map((f) => (
                            <option key={f.key} value={f.key}>
                              {f.label}
                            </option>
                          ))}
                      </select>
                    </div>
                    {table && !(table.fields || []).some((f) => f.type === "automation") ? (
                      <p className="auto-note">
                        That database has no automation column yet. Add one from the grid&rsquo;s
                        column menu, then choose it here.
                      </p>
                    ) : null}
                  </>
                ) : null}

                {panelKey === "capture" ? (
                  <>
                    <div className="auto-field">
                      <label htmlFor="auto-maxposts">Posts per pull</label>
                      <input
                        id="auto-maxposts"
                        className="auto-input"
                        type="number"
                        min={1}
                        /* ⭐ WAVE 26 · ITEM 7 / C5 — 12, THE VENDOR'S OWN CEILING, MEASURED.
                           ⛔ THIS BOX WAS THE LIVE DEFECT, not just an over-generous bound: it
                           offered 200 and fell back to 24, and `clean_config` REFUSES a `maxPosts`
                           the panel SENT above 12 (`clean_max_posts`, `submitted=True`). This
                           panel sends the key on every save of a `field_instagram`, so clearing
                           the box produced a 400 naming a cap the control had just invited the
                           user past. */
                        max={12}
                        value={maxPosts}
                        onChange={(e) => edit(setMaxPosts)(Number(e.target.value) || 10)}
                      />
                      <p className="auto-hint">
                        At most 12 — the provider returns a profile&rsquo;s top 12 posts and no
                        more.
                      </p>
                    </div>
                    {/* ⛔ THREE <h3> ESSAYS ON THE CAPTURE RUNGS lived here — the vendor by name,
                        datacenter egress, "204K not 204,312", which rung answers when. The
                        CHOICE a person makes is exact-or-estimated and what it costs; the rest
                        was the architecture explaining itself. DESIGN.md §4. */}
                    {/*
                      ⭐⭐ WAVE 28 · R5 + R7 — THREE HEADINGS DESCRIBED A LADDER THAT NO LONGER
                      EXISTS. "Exact counts" / "Post engagement" / "Estimated counts" named the
                      capture RUNGS, and R5 retired the free anonymous rung from enrichment
                      entirely: exact numbers or nothing, a vendor refusal reports blocked. A
                      panel offering to "switch it off to get exact numbers or nothing" describes
                      a choice the runner stopped having — a picture of the engine that disagrees
                      with the engine, which is the defect this module refuses everywhere else.
                      ⛔ THE MONEY SENTENCES WENT WITH THEM (R7): "charged per profile" and
                      "Charged per post, so it is its own switch". Spend is reported in the RUN
                      LOG against work already done, never as a permanent caption on a control.

                      ⚠ `paidReady` SURVIVES, and R5 makes it MORE load-bearing rather than less.
                      It is the one live reader of the flag, and with no free ladder underneath, a
                      workspace with no provider connected gets nothing at all rather than a thin
                      row — so the panel says so. That is a system fact, not a price.
                    */}
                    {/*
                      ⛔ THE SENTENCE MAKES NO CLAIM ABOUT WHAT THE SWITCHES DO, and the first cut
                      did: it read "post and comment data follow the switches above", which is
                      NOT what `postMetrics` gates. Measured against the engine —
                      `automation_engine.py:5358` ("likes/comments per post are bought, or the
                      engagement series does not grow") and `connectors_ig.py:710` ("a separate,
                      opt-in, ~13x-cost rung rather than a free by-product") — the profile pull
                      keeps up to `maxPosts` posts REGARDLESS of that switch; what the switch buys
                      is per-post ENGAGEMENT. R6 rules the LABEL ("Post data"), and the label is
                      the owner's to set; a caption asserting a mechanism is mine, and this one
                      would have been a picture of the engine that disagrees with the engine —
                      the defect this module refuses in five other places.
                      ⚠ ASK ->B is open on whether R5's reshape changes that. Until it is answered
                      the honest panel says the one thing that is true either way.
                    */}
                    <h3>Included</h3>
                    <p className="auto-hint">
                      The profile is always read.
                      {paidReady ? "" : " No provider is connected on this workspace yet."}
                    </p>
                  </>
                ) : null}

                {panelKey === "find" ? (
                  <AutomationFind
                    discover={discover}
                    recordsLimit={recordsLimit}
                    onRecordsLimit={(n) => {
                      edit(setRecordsLimit)(n);
                      setEstimate(null);
                    }}
                    joinOp={joinOp}
                    onJoinOp={edit(setJoinOp)}
                    preds={preds}
                    onPreds={edit(setPreds)}
                    estimate={estimate}
                    onEstimate={() => {
                      void discoverEstimate(recordsLimit)
                        .then(setEstimate)
                        .catch(() => setEstimate(null));
                    }}
                  />
                ) : null}

                {/*
                  ⭐ WAVE 26 · ITEM 17 / R15 — THE LAST OF THE WRITE PANEL'S PROSE IS DELETED.
                  Three paragraphs stood here after wave 24 had already cut four: "Profiles you
                  find are saved here for you to review", "Each run adds a dated row…", and
                  "Switch this off to test the automation without saving anything" (plus the
                  `field_instagram` rider that a dry run still costs). Each was true; none was a
                  decision anybody came to this panel to make.
                  ⛔ WHAT SURVIVES IS THE ONE CONTROL: `scrape_db`'s Database name. R15 is
                  "remove the explainer prose, KEEP THE SWITCHES", and the dry-run switch itself
                  was never here — it is the write node's `auto-step-switch` in the Builder, which
                  is exactly why this text could go without retiring anything.
                  ⚠ So for a `discover_instagram` this panel body is now EMPTY, deliberately: the
                  machine row above it still carries the node's name and its switch, and an empty
                  body is the honest rendering of "there is nothing to configure here".
                */}
                {panelKey === "write" && kind === "scrape_db" ? (
                  <div className="auto-field">
                    <label htmlFor="auto-tlabel">Database name</label>
                    <input
                      id="auto-tlabel"
                      className="auto-input"
                      value={targetLabel}
                      placeholder="S&P 500 companies"
                      onChange={(e) => edit(setTargetLabel)(e.target.value)}
                    />
                  </div>
                ) : null}
    </>
  );

  return (
    <div className="auto-detail">
      <header className="auto-head">
        {/*
          ⭐ ITEM 15b — THE TITLE ROW IS THE VIEW SWITCH NOW. It held an editable name, a run-state
          chip and a kind tag; all three left and Builder/Board took the space, left-aligned, with
          Run now / Save / Delete still on the right.

          WHERE EACH ONE WENT, because "removed" and "lost" are different claims:
            · the NAME is the first field of the Builder's Properties panel, writing on blur;
            · the STATE is the builder's own step chip, the Run-now button's "Running…", and the
              rail's per-automation dot — three surfaces that were already saying it;
            · the KIND stops being a user-facing fact at all this wave (R6: every new automation
              is `plain`), and the two legacy machine kinds describe themselves in Properties →
              Configuration → "How this fetches".

          ⚠ AND THE PAGE TITLE IS NOT DRAWN HERE EITHER WAY. The shell's `DbHead` (W23-W1) draws
          the SURFACE's name — the literal "Automation" off the nav payload — never this
          automation's. See the dated amendment under C-DETAIL: the contract's premise was wrong
          on that point and the consequence (no name in the Board view) is booked, not patched.
        */}
        <div className="auto-head-title">
          <span className="autox-view is-on">Builder</span>
        </div>
        <div className="auto-head-actions">
          {/*
            ⭐ W24-W1 — WHAT IT IS DOING, beside the button that started it. This is the whole of
            item 6's honest fix: "Running…" for two minutes is indistinguishable from a hung
            thread, and the engine has been publishing the step text the entire time.
            ⚠ IT IS A STATEMENT, NOT A CONTROL, and it renders only while there is something to
            say — `liveStepOf` returns `""` unless the automation is running, so this never shows
            a step left over from a run that finished.
          */}
          {liveStep ? (
            <span className="auto-livestep" role="status">
              {liveStep}
            </span>
          ) : runState.blocked ? (
            /* ⭐ ITEM 22 / D-70 — THE SENTENCE THE EXIT CONDITION ASKS FOR, on screen rather than
               in a tooltip. A disabled button with no explanation reads as "it broke"; this says
               the search is already paid for and collects itself. It takes the `liveStep` slot
               because they answer the same question and only one of them is ever true. */
            <span className="auto-livestep" role="status">
              {runState.why}
            </span>
          ) : null}
          {/* ⭐ ITEM 22 / D-70 / R12 — ONE RUN PER AUTOMATION, and the label says which reason.
              The guard is `runBlock`, not `automation.running`: `running` is process state and is
              FALSE for the whole 20-30 minute vendor wait, which is precisely the window in which
              a second paid search was measured being bought. */}
          <button
            type="button"
            className="auto-btn"
            disabled={runState.blocked}
            title={runState.why || undefined}
            onClick={() => void start()}
          >
            {runState.blocked ? runState.label : "Run now"}
          </button>
          <button
            type="button"
            className="auto-btn is-primary"
            disabled={saving || !name.trim()}
            onClick={() => void save()}
          >
            {saving ? "Saving…" : "Save"}
          </button>
          <button type="button" className="auto-btn is-danger" onClick={() => void remove()}>
            Delete
          </button>
        </div>
      </header>

      {problem ? (
        <div className="auto-banner is-error" role="alert">
          {problem}
        </div>
      ) : null}
      {message ? (
        <div className="auto-banner is-ok" role="status">
          {message}
        </div>
      ) : null}

      <div className="auto-work">
        {/*
          ⛔ THE VIEW IS CHOSEN, NEVER INFERRED (C14 leg 1). This block used to be
          `{board ? <Board/> : <Steps/>}` — the page's SHAPE decided by whether a fetch had
          answered — so every automation you opened drew one layout and then replaced it with
          another. That second paint, showing the previous automation's shape inside the new
          one's frame, is the ghost the owner reported.

          `AutomationSteps` is DELETED with that branch. It was the honest fallback for a server
          with no `/board`; the Builder is a better answer to the same question (it renders the
          engine's own steps AND what the owner added, from data the rail already had) and there
          is no longer a state in which the reader gets it by accident.
        */}
        <>
          <AutomationBuilder
            automation={automation}
            // ITEM 15b — the name left the header, and this is where it went. Three REQUIRED
            // props rather than one: the cron field's shape (controlled while typing, one write
            // on blur) needs the text and the commit to be separable, and an OPTIONAL name would
            // degrade to "the field does not exist", which is indistinguishable from never
            // having built it.
            name={name}
            // ⚠ NOT `edit(setName)` — the name has its own immediate write (`commitName`), so
            // marking the whole FORM dirty for it would leave `touched` true forever after a
            // name edit and make the panel's stamp say "unsaved changes" about something it had
            // just saved. `touched` now means exactly "a field the header's Save owns has been
            // edited", which is what the stamp below reports.
            onNameText={setName}
            onNameCommit={() => void commitName()}
            // ⭐ THE PANEL'S STAMP MUST NOT LIE, and item 5 is what made this urgent. "All changes
            // saved" was honest while Properties held only immediate-write controls; the machine
            // steps' panels moved in this wave and THOSE commit with the header's Save (url,
            // extract, the column map, the discovery filters). A stamp promising they are saved,
            // above the fields that are not, is the same defect as an unsaved name under it.
            dirty={touched}
            {...(triggers ? { triggers } : {})}
            catalog={catalog || []}
            {...(vocab ? { vocab } : {})}
            tables={tables}
            /* ⭐ ITEM 9 / R11 — an action's Database picker can now MINT one, so the list has to
               be able to catch up. REQUIRED rather than optional: an optional refresh degrades to
               "the database was created and does not appear", which is worse than no button. */
            onTablesChanged={reloadTables}
            oauth={oauth}
            busy={triggerBusy || saving}
            onPatch={(body) => void writeDefinition(body)}
            onToggleNode={(id) => void flip(id)}
            // ⛔ THE EXISTING TWO-WRITE SHAPE, not a second copy of it. `pickTrigger` writes
            // `trigger:{key}` AND `schedule.enabled` together, because the schedule half is
            // what today's engine reads — a builder that wrote only the key would leave "At a
            // scheduled time" selected on an automation with the cron switched off.
            onPickTrigger={(key) => void pickTrigger(key)}
            onRunNow={() => void start()}
            // Item 7's third clause: the IG trigger's Configuration carries the Scheduled-run
            // controls too, so the builder needs to know when to render the face it was handed.
            schedules={cronDriven}
            // Item 12a's root-cause fix — resolved HERE because it needs `discover.table`, which
            // is this component's prop and has no business being threaded into the builder just
            // to be read once.
            walkTable={walkTable}
            // ITEM 5 / C-CFG — handed over DIRECTLY now, not wrapped in `(node) => panelBody(
            // node.panel)`. The builder renders these grouped by panel key under "How this
            // fetches", so a wrapper taking a node would have to pick one node of a group and
            // imply the body belonged to it.
            renderNodeBody={panelBody}
            // ONE cron round-trip, rendered inside the Properties panel with its own picker
            // suppressed — the builder supplies the "Trigger type" select above it.
            scheduleFace={scheduleFace}
            // ⭐ WAVE 27 · C11 — the panel switch and its state, both owned here.
            showProperties={aside === "properties"}
            panelTabs={panelTabs}
            onStepPicked={() => setAside("properties")}
          />
          {/* ⚠ THE RUN LOG IS UNMOUNTED WHEN HIDDEN, unlike Properties, and the asymmetry is
              deliberate rather than an oversight: this panel holds no editing state (its only
              local state, the drill-down, is this component's and survives), while re-mounting
              it re-reads nothing — the runs ride on `automation`. Properties keeps a half-typed
              name and cron, so hiding it has to leave it alive. */}
          <aside
            className="auto-panel is-appearing"
            aria-label="Run history"
            hidden={aside !== "runs"}
          >
            <>
              <div className="auto-panel-head">
                {panelTabs}
              </div>
              {!automation.runs?.length ? (
                <p className="auto-note">
                  It has not run yet. Press Run now, or give it a trigger.
                </p>
              ) : (
                <ol className="auto-runs">
                  {automation.runs.map((r) => (
                    <li key={r.ts} className={"auto-run is-" + (r.ok ? "ok" : "error")}>
                      <button
                        type="button"
                        className="auto-run-head"
                        onClick={() => void openDrill(r)}
                        aria-expanded={drillFor === r.ts}
                      >
                        <span className="auto-run-ts">{r.ts.replace("T", " ")}</span>
                        <span className="auto-run-summary">{r.summary}</span>
                      </button>
                      <div className="auto-counts">
                        {COUNT_LABELS.filter(([k]) => typeof r.counts?.[k] === "number").map(
                          ([k, label]) => (
                            <span
                              key={k}
                              className={
                                "auto-count" + (k === "capped" && r.counts[k] ? " is-warn" : "")
                              }
                            >
                              <b>{r.counts[k]}</b> {label}
                            </span>
                          )
                        )}
                      </div>
                      {/* ⭐⭐ D-103 — WHAT ACTUALLY HAPPENED, PER RECORD. The counts row above
                          answers "how many" and the summary answers "roughly what"; neither
                          could ever answer "why", which is the only question somebody opens this
                          panel with. These are the vendor's own sentences, not our paraphrase.
                          ⚠ Rendered unconditionally rather than behind the drill-down: the drill
                          is a paid-ish round trip that lists ROWS, and a reason you have to go
                          looking for is most of the way back to not having it. */}
                      {r.notes?.length ? (
                        <ul className="auto-run-notes">
                          {r.notes.map((n, i) => (
                            <li key={i}>{n}</li>
                          ))}
                        </ul>
                      ) : null}
                      {drillFor === r.ts && drill ? (
                        <div className="auto-drill">
                          <p className="auto-hint">
                            The rows this run touched in {drill.label}
                            {drill.truncated ? " (first 200)" : ""}.
                          </p>
                          <div className="auto-drill-scroll">
                            <table className="auto-table">
                              <thead>
                                <tr>
                                  {drill.fields.map((f) => (
                                    <th key={f.key}>{f.label}</th>
                                  ))}
                                </tr>
                              </thead>
                              <tbody>
                                {drill.rows.slice(0, 50).map((row) => (
                                  <tr key={String(row.id)}>
                                    {drill.fields.map((f) => (
                                      <td key={f.key}>{String(row[f.key] ?? "")}</td>
                                    ))}
                                  </tr>
                                ))}
                              </tbody>
                            </table>
                          </div>
                        </div>
                      ) : null}
                    </li>
                  ))}
                </ol>
              )}
              <p className="auto-hint">
                Open a run to inspect the records it touched.
              </p>
            </>
          </aside>
        </>
      </div>
    </div>
  );
}
