// ---------------------------------------------------------------------------
// query/QueryPage.tsx — THE QUERY MODULE, which is the DATABASE MODULE (owner item 2,
// 2026-08-14).
//
// Owner, verbatim: *"the query module should exactly BE looking like the database module,
// COMPLETELY, with all the views etc. The only difference is that user isn't the one
// creating the view, it's the AI that they prompt in the AI assistant module."*
//
// ⛔ "EXACTLY" AND "COMPLETELY" ARE ONLY SATISFIABLE ONE WAY: by mounting THE SAME frame
// and THE SAME grid the database route mounts — `shell-db-frame` + `DbHead` +
// `OverlayProvider` + `CustomerGrid` — so every view kind, the view sidebar, filters,
// grouping, the row detail, the export, the undo stack and everything else arrive by
// being the same component rather than by being rebuilt. A hand-rolled list of AI views
// beside a read-only preview would satisfy the sentence's words and none of its meaning.
// `DbHead` and `gridScopeFor` moved out of `Shell.tsx` into `shell/dbFrame.tsx` for this;
// nothing about them changed.
//
// ⛔ WHAT THIS PAGE IS *NOT* ANY MORE: the ask box. It used to carry the question field,
// the read-back and the Keep/Discard pair, above a list of rows — which is a form, not a
// database. All of that moved to `assistant/AssistantPage.tsx`, the surface the owner
// names as the one difference between this module and a database ("the AI that they
// prompt in the AI assistant module"). This page reads `GET /query` only to know WHICH
// views the assistant built and which database each belongs to.
//
// ⛔ THE VIEW IS SELECTED THROUGH `VIEW_OPEN_EVENT`, NOT A PROP, and that is a re-use
// rather than a shortcut. That channel's contract: *"the SHELL routes to the table and
// the GRID owns view selection, so neither has to learn the other's state."* Two traps
// ride with it and both are handled here:
//   · the listener guards on `detail.topic !== scope` — the GRID SCOPE spelling, not the
//     registry key — and drops anything else SILENTLY. `gridScopeFor` is what makes the
//     two agree; emitting the saved row's `scope` verbatim would select nothing on
//     `customer_data` / `product_data` and look exactly like a click that "worked".
//   · the listener also drops a view it has not loaded yet, and there is no ack. So the
//     emit rides `retryEmit` on THIS module's own ladder (`VIEW_SELECT_RETRY_MS`), which is
//     longer than the inbox's for a measured reason — see that constant.
//
// ⚠ NO CLIENT UNION OVER `kind` — the server's vocabulary arrives as a string (the
// wave-9 law), so a kind we have no label for renders as itself rather than dropping the
// row from the picker.
// ---------------------------------------------------------------------------

import { useCallback, useEffect, useRef, useState } from "react";

import { VIEW_OPEN_EVENT, signal } from "../apiContract";
import type { QueryOpenDetail } from "../apiContract";
import { QUERY_OPEN_EVENT } from "../apiContract";
import CustomerGrid from "../customer-grid/CustomerGrid";
import { OverlayProvider } from "../customer-grid/OverlaySurface";
import { retryEmit } from "../inbox/inboxModel";
import { DbHead, gridScopeFor } from "../shell/dbFrame";
import { databaseEntries } from "../shell/nav";
import type { NavEntry } from "../shell/nav";
import { fetchQueries } from "./queryApi";
import type { SavedQuery } from "./queryApi";
// ⛔ THE TWO PIECES THIS MODULE ADDS LIVE IN A LEAF, not here — see `queryParts.tsx`'s header.
// This file imports `CustomerGrid`, so anything defined beside it can only be render-tested by
// dragging the whole spreadsheet engine into plain node, which does not survive the trip.
import { QueryEmpty, QueryPicker } from "./queryParts";
import "./query.css";

export interface QueryPageProps {
  /**
   * ⛔ REQUIRED, and it is the nav's own entries. An optional prop here would degrade to
   * "the feature does not exist" the first time a mount forgot it, which is
   * indistinguishable from never having been built. It is also what makes this a legal
   * CHROME route: the labels and icons this page draws are the ones the SERVER already
   * sent, so it renders nothing that was not already granted.
   */
  granted: NavEntry[];
}

/**
 * ⭐⭐ THIS MODULE'S OWN RE-EMIT LADDER (ms), and it is LONGER than the inbox's on purpose.
 *
 * ⛔ THE NUMBERS COME FROM A MEASUREMENT, NOT FROM CAUTION. `OPEN_RETRY_MS` ends at 2,600 ms and
 * is right for its case: a notification click into a grid that is usually already warm. This
 * module's case is the opposite — it keys `CustomerGrid` FRESH on every view switch, so the
 * workspace read is cold, and CLAUDE.md records `ut_assembly` at **799 ms warm / 20.6 s cold**
 * with live database switches at 1.8–7.3 s. A 2.6 s ladder against a 20.6 s read runs out before
 * the grid has views to select from, the listener's `views.some(...)` guard drops every attempt,
 * and there is NO ACK to notice it with.
 *
 * ⛔ THE FAILURE THAT BUYS IS WORSE THAN A SLOW PAINT, which is why the ladder is the fix rather
 * than a spinner: the header would say *"View: Big accounts"* while the grid painted its own
 * default view. A wrong claim, silently, with the picker asserting it. (Worse than the case this
 * ladder was borrowed from — there the header made no competing claim.)
 *
 * ⚠ RE-EMITTING IS FREE: `selectView` early-returns once the view is active and the listener
 * drops a duplicate, so the tail rungs cost nothing on the ordinary warm path — they are only
 * ever reached by a load that is genuinely still in flight.
 */
export const VIEW_SELECT_RETRY_MS = [0, 250, 700, 1500, 2600, 5000, 9000, 14000, 21000] as const;

export default function QueryPage({ granted }: QueryPageProps) {
  const [views, setViews] = useState<SavedQuery[]>([]);
  const [activeId, setActiveId] = useState("");
  const [loaded, setLoaded] = useState(false);
  const emitCancel = useRef<(() => void) | null>(null);

  const entries = databaseEntries(granted);
  const entryOf = (key: string) => entries.find((e) => e.key === key);

  /**
   * ⛔ ONLY THE VIEWS WHOSE DATABASE THE NAV NAMED. Two things depend on that entry — the
   * header's label and the grid's icon — and without it `dbLabelOf` would fall back to the raw
   * store key, painting `ut_leads` as the name of a database. The picker's own render test
   * asserts against exactly that leak (*"never the raw store key"*); the header is one file over
   * and would have been unguarded.
   *
   * ⚠ IT IS ALSO THE CHROME LAW, applied on this end too: *"a chrome route renders nothing the
   * server did not already grant."* `GET /query` prunes against the wall, so this normally drops
   * nothing at all; it bites when `/nav` came back `degraded: ["databases"]` — a 200 whose
   * database list is missing — and rendering a grid we cannot even name from that state would be
   * guessing. `unnamed` carries the count so the empty face can say WHICH silence this is
   * instead of claiming the workspace has no views.
   */
  const known = views.filter((v) => entryOf(v.scope));
  const unnamed = views.length - known.length;

  // ⛔ `?? known[0]` — NOT just the find. `activeId` is seeded by the effect below, so the FIRST
  // render after a non-empty fetch has `activeId === ""` and `loaded === true`, which painted
  // "No views yet" for one frame at somebody who has views. That is this file's own two-facts-
  // two-sentences rule broken by render ordering. The effect stays as the correctness guard for
  // an id that was pruned away; this is what stops a wrong sentence reaching a screen at all.
  const active = known.find((v) => v.id === activeId) ?? known[0] ?? null;
  const dbLabelOf = (key: string) => entryOf(key)?.label ?? key;

  useEffect(() => {
    let alive = true;
    void (async () => {
      const res = await fetchQueries();
      if (!alive) return;
      setLoaded(true);
      if (res.ok) setViews(res.value.views);
    })();
    return () => {
      alive = false;
    };
  }, []);

  // The first view is the landing one. ⚠ Pinned against the CURRENT list rather than set
  // once: the index prunes on read (a grant withdrawn, a database deleted), so an id that
  // was valid a moment ago can leave — and a header naming a view the grid is not showing
  // is worse than no selection at all.
  useEffect(() => {
    if (known.length && !known.some((v) => v.id === activeId)) setActiveId(known[0].id);
  }, [known, activeId]);

  /**
   * ⛔ THE EMIT, AND ITS TOPIC IS THE **GRID SCOPE**. See the header: the listener compares
   * against its own `scope` prop, so a registry key here selects nothing and reports
   * nothing. The ladder covers the other half — the grid drops a view it has not loaded.
   */
  useEffect(() => {
    if (!active) return;
    emitCancel.current?.();
    const topic = gridScopeFor(active.scope);
    emitCancel.current = retryEmit(
      () => {
        signal(VIEW_OPEN_EVENT, { topic, viewId: active.viewId });
      },
      undefined,
      VIEW_SELECT_RETRY_MS
    );
    return () => emitCancel.current?.();
  }, [active]);

  /** The assistant's "Open in Query" click-through: route, then ASK. An event naming a
   *  query this page does not have is DROPPED — the list prunes on read, and a stale
   *  click must do nothing rather than select something else. */
  useEffect(() => {
    const onOpen = (e: Event) => {
      const detail = (e as CustomEvent<QueryOpenDetail>).detail;
      const qid = detail?.qid;
      if (!qid || !views.some((v) => v.id === qid)) return;
      setActiveId(qid);
    };
    window.addEventListener(QUERY_OPEN_EVENT, onOpen);
    return () => window.removeEventListener(QUERY_OPEN_EVENT, onOpen);
  }, [views]);

  const pick = useCallback((qid: string) => setActiveId(qid), []);

  if (!active) return <QueryEmpty loaded={loaded} unnamed={unnamed} />;

  const entry = entryOf(active.scope);
  return (
    // ⛔ THE SAME THREE CLASSES AS THE DATABASE ROUTE (`Shell.tsx`'s native branch), in the
    // same nesting. `.shell-db-frame > .shell-grid-host` is what gives glide its height;
    // renaming them for tidiness would silently collapse the grid to nothing.
    <div className="shell-db-frame">
      <DbHead
        label={dbLabelOf(active.scope)}
        {...(entry?.icon ? { icon: entry.icon } : {})}
        aside={
          <QueryPicker
            views={known}
            /* ⚠ `active.id`, NOT `activeId`. They differ on exactly one render — the first after
               a non-empty fetch, when the seeding effect has not run and `activeId` is still "".
               A `<select>` whose value matches no option renders BLANK, so the header would show
               an empty picker over a grid that is loading a real view. Same one-frame ordering
               bug as the `?? known[0]` above, one control over. */
            activeId={active.id}
            dbLabelOf={dbLabelOf}
            onPick={pick}
          />
        }
      />
      <div className="shell-grid-host">
        <OverlayProvider>
          {/* ⚠ `key` ON THE SCOPE, so switching to a view on a DIFFERENT database remounts
              rather than re-pointing a grid that still holds the old table's rows, fields and
              undo stack — the same reason the shell keys this component on the route. */}
          <CustomerGrid
            key={gridScopeFor(active.scope)}
            scope={gridScopeFor(active.scope)}
          />
        </OverlayProvider>
      </div>
    </div>
  );
}
