// ---------------------------------------------------------------------------
// customer-grid / SwipeView.tsx
// ⭐ WAVE-27 item 8 (owner ruling R2, contract C3) — the SWIPE deck.
//
// One record at a time, decided left or right into two options of ONE
// single-select. R2 fixes the shape and it is narrow on purpose:
//   · SINGLE-SELECT ONLY, one field, two of its options. No checkbox binding.
//   · the deck holds only the records whose bound field is EMPTY — a record
//     that already has a value has been decided, and re-asking is not triage.
//   · a swipe writes through the NORMAL cell door (`onSwipe`, which the host
//     maps to the same `patchAndRecord` a kanban card move uses), so undo,
//     echo-suppression and the permission wall all hold without being
//     re-implemented here.
//
// Honesty rules carried from the other modes:
//   · nothing is silently hidden — the deck states what is left, and the
//     empty state names the bound field rather than saying "all done".
//   · a lost binding is SHOWN, never repaired by guessing. `_clean_display`
//     validates that `fieldKey` names a field the table HAS and stops there;
//     it cannot know the field is still a select, or that the two options are
//     still in its vocabulary (its docstring draws that line explicitly). So
//     those three losses land here, and each one says what happened and
//     re-opens the picker — the `viewModes.tsx:190-194` rule, which exists
//     because the wave-7 trap was a picker silently falling back to its first
//     option.
// ---------------------------------------------------------------------------

import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { choiceOptions } from "./types";
import type { CustomerDoc, DisplaySpec, Field, Row, Viewer } from "./types";
// ⚠ `SurfaceScope` is `apiBridge`'s (a TYPE-only import, so nothing of that module is
//   loaded here) — the same import `RecordComments` takes for the same prop.
import type { SurfaceScope } from "./apiBridge";
import RecordComments from "./RecordComments";
import { Documents } from "./Documents";
import { formatDisplay } from "./cells";
import { actionHref } from "./display";
import { optionTint } from "./choiceColors";
import { ModeIcon } from "./icons";
// ⭐ W29-T73 — the line its three siblings all have, and the one this file shipped without.
// `SwipeView.css` had ZERO references anywhere in `src/`, so T26's two new sections rendered
// unstyled while `.cg-swipe-card` (which lives in `index.css`) kept the card looking right —
// a partially-styled surface reads as a design choice, which is why nobody reported it and no
// gate could see it: a side-effect import references no symbol ([[artifact-with-no-importer]]).
import "./SwipeView.css";

export type SwipeSpec = NonNullable<DisplaySpec["swipe"]>;

/** The three ways a stored binding can rot that the host cannot see (see the header note). */
type BindingFault =
  | { kind: "unbound" }
  | { kind: "field-gone"; fieldKey: string }
  | { kind: "not-select"; field: Field }
  | { kind: "option-gone"; field: Field; missing: string[] };

function readBinding(spec: SwipeSpec | undefined, fieldByKey: Map<string, Field>):
  { ok: true; field: Field; spec: SwipeSpec } | { ok: false; fault: BindingFault } {
  if (!spec) return { ok: false, fault: { kind: "unbound" } };
  const field = fieldByKey.get(spec.fieldKey);
  if (!field) return { ok: false, fault: { kind: "field-gone", fieldKey: spec.fieldKey } };
  // R2 — SINGLE-select only. `multiselect` is deliberately not accepted: its cell holds a SET,
  // so "write this option" would mean append-or-replace, and a triage gesture that sometimes
  // adds and sometimes overwrites is two gestures wearing one button.
  if (field.type !== "select") return { ok: false, fault: { kind: "not-select", field } };
  const options = choiceOptions(field);
  const has = new Set(options.map((o) => o.toLowerCase()));
  const missing = [spec.leftOption, spec.rightOption].filter((o) => !has.has(o.toLowerCase()));
  if (missing.length) return { ok: false, fault: { kind: "option-gone", field, missing } };
  return { ok: true, field, spec };
}

/** The bound cell is EMPTY — the one predicate that decides deck membership (R2). */
const isUndecided = (row: Row, key: string): boolean =>
  String(row[key] ?? "").trim() === "";

export function SwipeView({
  rows,
  fields,
  fieldByKey,
  spec,
  cardKeys,
  detailKeys,
  scope,
  viewer,
  docs,
  docPayload,
  onDocAdd,
  onDocFetch,
  onDocDelete,
  titleKey,
  canWrite,
  readOnlyReason,
  canBind,
  onSpec,
  onSwipe,
  onOpen,
}: {
  /** DISTINCT data rows from the full pipeline, overlay edits layered — the kanban's contract.
   *  The deck re-derives from these, so a written record leaves it as soon as the optimistic
   *  patch lands; there is no local cursor to drift out of step with the data. */
  rows: Row[];
  /** Every field, for the picker's field list. */
  fields: Field[];
  fieldByKey: Map<string, Field>;
  /** The stored binding, or undefined for a deck nobody has configured yet. */
  spec: SwipeSpec | undefined;
  /** The handful of visible fields the card lists under its title. */
  cardKeys: string[];
  titleKey: string;
  /** May this viewer write the BOUND field's value? (the kanban's `canMove`) */
  canWrite: boolean;
  /** Stated when the deck cannot be decided — permissions, or a computed column. */
  readOnlyReason: string | null;
  /** May this viewer change what the deck is bound TO? A VIEW-config act, so it is the
   *  view-editing permission and NOT `canWrite` — conflating the two is
   *  [[schema-role-is-not-a-value-wall]]. REQUIRED, because a picker that silently does
   *  nothing is worse than no picker ([[wrong-parent-not-broken-control]]). */
  canBind: boolean;
  /** `undefined` DELETES the binding — absent is the honest unconfigured state, and storing a
   *  half-binding is what `cleanDisplay` drops on both engines (the `kanbanClamp` law). */
  onSpec: (next: SwipeSpec | undefined) => void;
  onSwipe: (pid: number, value: string) => void;
  onOpen: (pid: number) => void;
  /**
   * ⭐⭐ WAVE-29 T26 (owner R9) — WHAT MAKES THE CARD A RECORD RATHER THAN A SUMMARY.
   *
   * R9: *"the swipe CARD itself renders the record detail inline — fields, comments and
   * attachments — so a reviewer decides without leaving the deck."* Everything below is that,
   * and every one of them is OPTIONAL for one reason: this deck runs on hosts that supply
   * different amounts. A card must render with none of them rather than throw — the standalone
   * embed has no `scope`, and a deployment with no document storage serves no handlers.
   */
  /** Every field the VIEW shows, in its order — not the three-key card summary. */
  detailKeys?: string[];
  /** The surface these records live on. Comments are keyed by it; absent ⇒ no comments section,
   *  which is the pre-existing precondition `RecordDetail` already carries. */
  scope?: SurfaceScope;
  viewer?: Viewer;
  /** This record's attachments, and the plumbing. Handlers absent ⇒ the section is not rendered
   *  at all, exactly as `RecordDetail` decides it (a dead upload control is a promise the app
   *  cannot keep). */
  /** Keyed by pid, because a deck shows many records — `RecordDetail` takes ONE record's list
   *  and that shape cannot serve a deck. */
  docs?: Record<string, CustomerDoc[]>;
  docPayload?: { pid: number; docId: string; name: string; mime: string; data_b64: string };
  /** ⚠ EVERY HANDLER TAKES THE PID, unlike `RecordDetail`'s, whose host closes over the ONE
   *  open record. A deck paints many records at once, so a handler bound to a single pid would
   *  attach every reviewer's upload to whichever card happened to be open. */
  onDocAdd?: (
    pid: number,
    file: { name: string; mime: string; size: number; data_b64: string }
  ) => void;
  onDocFetch?: (pid: number, docId: string) => void;
  onDocDelete?: (pid: number, docId: string) => void;
}) {
  // "Later" — session-only, never stored. A deck with no way past a record you cannot decide
  // blocks on that record forever, so this is navigation WITHIN the deck rather than a third
  // gesture: it writes nothing, survives no reload, and the count is disclosed in the empty
  // state rather than quietly shrinking the deck (rule 8b).
  const [later, setLater] = useState<Set<number>>(new Set());
  const deckRef = useRef<HTMLDivElement>(null);

  const binding = useMemo(() => readBinding(spec, fieldByKey), [spec, fieldByKey]);
  const boundKey = binding.ok ? binding.field.key : null;

  const undecided = useMemo(
    () => (boundKey ? rows.filter((r) => isUndecided(r, boundKey)) : []),
    [rows, boundKey]
  );
  const deck = useMemo(() => undecided.filter((r) => !later.has(r.pid)), [undecided, later]);
  const card = deck[0];

  // A record decided elsewhere (the grid, another user's echo) must not stay "later" forever —
  // otherwise the disclosed skip count drifts away from what is actually on the deck.
  useEffect(() => {
    setLater((prev) => {
      if (prev.size === 0) return prev;
      const live = new Set(undecided.map((r) => r.pid));
      const next = new Set([...prev].filter((pid) => live.has(pid)));
      return next.size === prev.size ? prev : next;
    });
  }, [undecided]);

  const decide = useCallback(
    (value: string) => {
      if (!card || !canWrite) return;
      onSwipe(card.pid, value);
    },
    [card, canWrite, onSwipe]
  );

  // ← / → decide, and they are the SAME two actions the buttons are rather than a second code
  // path: the whole point of the mode is that one hand can clear a queue.
  useEffect(() => {
    if (!binding.ok || !canWrite || !card) return;
    const onKey = (e: KeyboardEvent) => {
      if (e.key !== "ArrowLeft" && e.key !== "ArrowRight") return;
      const el = document.activeElement as HTMLElement | null;
      // Never steal an arrow key from a text field or a native control the user is inside.
      if (el && (el.tagName === "INPUT" || el.tagName === "SELECT" || el.tagName === "TEXTAREA"))
        return;
      if (!deckRef.current?.contains(el ?? null) && el !== document.body) return;
      e.preventDefault();
      decide(e.key === "ArrowLeft" ? binding.spec.leftOption : binding.spec.rightOption);
    };
    window.addEventListener("keydown", onKey);
    return () => window.removeEventListener("keydown", onKey);
  }, [binding, canWrite, card, decide]);

  if (!binding.ok) {
    return (
      <div className="cg-swipe" ref={deckRef}>
        <SwipeBinder
          fault={binding.fault}
          fields={fields}
          fieldByKey={fieldByKey}
          spec={spec}
          canBind={canBind}
          onSpec={onSpec}
        />
      </div>
    );
  }

  const { field } = binding;
  const leftTint = optionTint(field, binding.spec.leftOption);
  const rightTint = optionTint(field, binding.spec.rightOption);

  return (
    <div className="cg-swipe" ref={deckRef} tabIndex={-1}>
      <div className="cg-swipe-bar">
        <span className="cg-swipe-bound">
          <ModeIcon mode="swipe" />
          Sorting into <strong>{field.label}</strong>
        </span>
        <span className="cg-swipe-left">
          {deck.length.toLocaleString()} {deck.length === 1 ? "record" : "records"} to sort
        </span>
        {canBind && (
          <button
            type="button"
            className="cg-link-btn"
            onClick={() => onSpec(undefined)}
            title="Choose a different field or different options for this deck"
          >
            Change
          </button>
        )}
      </div>
      {readOnlyReason && <div className="cg-kb-note">{readOnlyReason}</div>}
      {card ? (
        <div className="cg-swipe-deck">
          <button
            type="button"
            className="cg-swipe-side cg-swipe-side--left"
            disabled={!canWrite}
            style={leftTint ? { background: leftTint.bg, color: leftTint.fg } : undefined}
            onClick={() => decide(binding.spec.leftOption)}
            title={`Set ${field.label} to ${binding.spec.leftOption} (left arrow key)`}
          >
            <span aria-hidden className="cg-swipe-arrow">
              <svg width="16" height="16" viewBox="0 0 16 16">
                <path
                  d="M11 3.2 6.2 8l4.8 4.8"
                  fill="none"
                  stroke="currentColor"
                  strokeWidth="1.6"
                  strokeLinecap="round"
                  strokeLinejoin="round"
                />
              </svg>
            </span>
            {binding.spec.leftOption}
          </button>
          <article
            className="cg-swipe-card"
            role="button"
            tabIndex={0}
            onClick={() => onOpen(card.pid)}
            onKeyDown={(e) => {
              if (e.key !== "Enter" && e.key !== " ") return;
              e.preventDefault();
              onOpen(card.pid);
            }}
          >
            <h3 className="cg-swipe-title">{String(card[titleKey] ?? "")}</h3>
            <div className="cg-swipe-cells">
              {/* ⭐ WAVE-29 T26 (R9) — the RECORD's visible fields, not the three-key summary the
                  kanban card lends. `detailKeys` falls back to `cardKeys` so a host that has not
                  been widened yet renders exactly what it rendered before. */}
              {(detailKeys && detailKeys.length ? detailKeys : cardKeys).map((k) => {
                const f = fieldByKey.get(k);
                if (!f) return null;
                const text = formatDisplay(f, card[k]);
                if (!text) return null;
                // A url cell is a LINK here too (wave-26 item 11), through `display.ts`'s one
                // scheme guard — and every gesture that reaches it must be stopped from also
                // reaching the card, which is a button (the kanban card's note, same trap).
                const href = actionHref(f, card[k]);
                return (
                  <div key={k} className="cg-swipe-cell">
                    <span className="cg-lv-k">{f.label}</span>
                    {href ? (
                      <a
                        className="cg-lv-v"
                        href={href}
                        target="_blank"
                        rel="noopener noreferrer"
                        title={text}
                        onClick={(e) => e.stopPropagation()}
                        onKeyDown={(e) => e.stopPropagation()}
                      >
                        {text}
                      </a>
                    ) : (
                      <span className="cg-lv-v">{text}</span>
                    )}
                  </div>
                );
              })}
            </div>
            {/* ⭐ R9's other two thirds. Both stop their own events: the card is a `role="button"`
                that opens the record, and a click on a comment box or an upload control must not
                also open the modal it exists to make unnecessary (the kanban card's link trap,
                one surface over). */}
            {onDocAdd && onDocFetch && onDocDelete && (
              <div
                className="cg-swipe-docs"
                onClick={(e) => e.stopPropagation()}
                onKeyDown={(e) => e.stopPropagation()}
                role="presentation"
              >
                <Documents
                  pid={Number(card.pid)}
                  docs={docs?.[String(card.pid)] ?? []}
                  docPayload={docPayload}
                  onAdd={(file) => onDocAdd(card.pid, file)}
                  onFetch={(docId) => onDocFetch(card.pid, docId)}
                  onDelete={(docId) => onDocDelete(card.pid, docId)}
                />
              </div>
            )}
            {scope != null && (
              <div
                className="cg-swipe-comments"
                onClick={(e) => e.stopPropagation()}
                onKeyDown={(e) => e.stopPropagation()}
                role="presentation"
              >
                {/* ⚠ KEYED ON scope+pid, the same key `RecordDetail` uses: without it React
                    reuses the mounted instance across a card change and paints one record's
                    comments under another's name until the fetch returns. */}
                <RecordComments
                  key={`${scope}:${Number(card.pid)}`}
                  scope={scope}
                  pid={Number(card.pid)}
                  viewer={viewer}
                />
              </div>
            )}
            {canWrite && (
              <button
                type="button"
                className="cg-link-btn cg-swipe-later"
                onClick={(e) => {
                  e.stopPropagation();
                  setLater((prev) => new Set(prev).add(card.pid));
                }}
              >
                Decide later
              </button>
            )}
          </article>
          <button
            type="button"
            className="cg-swipe-side cg-swipe-side--right"
            disabled={!canWrite}
            style={rightTint ? { background: rightTint.bg, color: rightTint.fg } : undefined}
            onClick={() => decide(binding.spec.rightOption)}
            title={`Set ${field.label} to ${binding.spec.rightOption} (right arrow key)`}
          >
            {binding.spec.rightOption}
            <span aria-hidden className="cg-swipe-arrow">
              <svg width="16" height="16" viewBox="0 0 16 16">
                <path
                  d="M5 3.2 9.8 8 5 12.8"
                  fill="none"
                  stroke="currentColor"
                  strokeWidth="1.6"
                  strokeLinecap="round"
                  strokeLinejoin="round"
                />
              </svg>
            </span>
          </button>
        </div>
      ) : (
        // The empty state NAMES the bound field (C3), because "nothing to sort" is ambiguous
        // between "the deck is finished" and "the filter hid everything", and the two want
        // different next actions. `later` is disclosed rather than quietly subtracted.
        <div className="cg-mode-empty cg-swipe-empty">
          {later.size > 0 ? (
            <>
              <p>
                {later.size.toLocaleString()}{" "}
                {later.size === 1 ? "record is" : "records are"} set aside for later. Nothing else
                in this view is missing a <strong>{field.label}</strong>.
              </p>
              <button type="button" className="cg-btn" onClick={() => setLater(new Set())}>
                Bring them back
              </button>
            </>
          ) : (
            <p>
              Every record in this view already has a <strong>{field.label}</strong>. Widen the
              view's filters to sort more.
            </p>
          )}
        </div>
      )}
    </div>
  );
}

/**
 * The binding picker: field + two options. It is also the surface every LOST binding lands on,
 * with the loss stated above it — one place that answers "what is this deck for", rather than an
 * error screen beside a separate setup screen.
 */
function SwipeBinder({
  fault,
  fields,
  fieldByKey,
  spec,
  canBind,
  onSpec,
}: {
  fault: BindingFault;
  fields: Field[];
  fieldByKey: Map<string, Field>;
  spec: SwipeSpec | undefined;
  canBind: boolean;
  onSpec: (next: SwipeSpec | undefined) => void;
}) {
  const selects = useMemo(
    () => fields.filter((f) => f.type === "select" && choiceOptions(f).length >= 2),
    [fields]
  );
  const [fieldKey, setFieldKey] = useState<string>(() => {
    if (spec && fieldByKey.get(spec.fieldKey)?.type === "select") return spec.fieldKey;
    return selects[0]?.key ?? "";
  });
  const chosen = fieldByKey.get(fieldKey);
  const options = useMemo(() => (chosen ? choiceOptions(chosen) : []), [chosen]);
  const [left, setLeft] = useState<string>("");
  const [right, setRight] = useState<string>("");

  // The two option selects follow the FIELD. Kept in an effect rather than derived so the user's
  // pick survives re-renders, and reset whenever the field changes — carrying an option from the
  // previous field would offer a value the new field cannot hold.
  useEffect(() => {
    setLeft(options[0] ?? "");
    setRight(options.find((o) => o !== options[0]) ?? "");
  }, [options]);

  const message = ((): string => {
    switch (fault.kind) {
      case "field-gone":
        return "The field this deck sorted into has been deleted. Pick another one.";
      case "not-select":
        return `“${fault.field.label}” is no longer a single-select, so it has no options to`
          + " sort into. Pick another field.";
      case "option-gone":
        return `${fault.missing.map((m) => `“${m}”`).join(" and ")} ${
          fault.missing.length === 1 ? "is" : "are"
        } no longer ${fault.missing.length === 1 ? "an option" : "options"} on “${
          fault.field.label
        }”. Pick the sides again.`;
      default:
        return "Sort records one at a time into two options of a single-select field.";
    }
  })();

  if (!canBind) {
    return (
      <div className="cg-mode-empty">
        <p>{message}</p>
        <p>Its creator or an admin can set this view up.</p>
      </div>
    );
  }
  if (selects.length === 0) {
    // Honest and specific: the mode is not broken, the TABLE has nothing to bind to. Naming the
    // requirement is what turns a dead end into a next action.
    return (
      <div className="cg-mode-empty">
        <p>
          A swipe deck sorts into a single-select field with at least two options. This database
          does not have one yet — add a single-select column, then come back.
        </p>
      </div>
    );
  }
  const ready = !!chosen && !!left && !!right && left.toLowerCase() !== right.toLowerCase();
  return (
    <div className="cg-swipe-setup">
      <p className="cg-swipe-setup-note">{message}</p>
      <label className="cg-swipe-setup-row">
        <span>Sort into</span>
        <select
          className="cg-select"
          value={fieldKey}
          onChange={(e) => setFieldKey(e.target.value)}
        >
          {/* Every option carries an explicit `value`: a <select> whose value names nothing
              renders its FIRST option and reports a choice the user never made
              ([[cg-condition-builder-items]]). */}
          {selects.map((f) => (
            <option key={f.key} value={f.key}>
              {f.label}
            </option>
          ))}
        </select>
      </label>
      <div className="cg-swipe-setup-sides">
        <label className="cg-swipe-setup-row">
          <span>Swipe left</span>
          <select className="cg-select" value={left} onChange={(e) => setLeft(e.target.value)}>
            {options.map((o) => (
              <option key={o} value={o}>
                {o}
              </option>
            ))}
          </select>
        </label>
        <label className="cg-swipe-setup-row">
          <span>Swipe right</span>
          <select className="cg-select" value={right} onChange={(e) => setRight(e.target.value)}>
            {options.map((o) => (
              <option key={o} value={o}>
                {o}
              </option>
            ))}
          </select>
        </label>
      </div>
      {!ready && left && right && (
        // Stated, not silently refused: both engines DROP a binding whose sides are the same
        // option, so a Save that looked like it worked would simply not persist.
        <p className="cg-swipe-setup-warn">
          The two sides must be different options — otherwise both gestures do the same thing.
        </p>
      )}
      <button
        type="button"
        className="cg-btn cg-btn--primary"
        disabled={!ready}
        onClick={() =>
          chosen && onSpec({ fieldKey: chosen.key, leftOption: left, rightOption: right })
        }
      >
        Start sorting
      </button>
    </div>
  );
}

export default SwipeView;
