// ---------------------------------------------------------------------------
// forms / FormPublic.tsx — ⭐ wave-23 item 8 (contract W23-C9).
//
// The page an ANONYMOUS person lands on at `#/form/<token>`. E mounts it in the
// Shell BEFORE the auth wall (wiring W23-W2); everything below assumes there is
// no session, no workspace, no nav and no viewer.
//
// ⛔ IT IMPORTS NOTHING FROM `customer-grid/**`, AND THAT IS A REQUIREMENT, NOT
// A PREFERENCE. The grid bundle drags glide-data-grid — hundreds of kilobytes
// of canvas table — into whatever chunk touches it, and this page is the one
// surface in the product that a stranger loads cold, once, probably on a phone,
// probably on mobile data, to type four answers. One convenience import from
// `types.ts` would put the whole spreadsheet engine on that wire. So the field
// vocabulary below is DECLARED HERE as plain strings rather than imported: the
// server sends `type` as a string and this page renders a control per string,
// which is the same "no client union over a server vocabulary" rule the rest of
// the wave follows, arriving here for a second reason.
//
// ⚠ Styling reuses `.lpf-*` rules in the D REGION of index.css — the shell's
// stylesheet is one file and already loaded; a second stylesheet for one page
// would be a second copy of the tokens.
// ---------------------------------------------------------------------------

import { useEffect, useState } from "react";

/** One field, exactly as `routes_forms._public_form` builds it. Nothing here is
 *  optional-because-maybe: every key below is one the server always sends, and
 *  the three that are conditional say so. */
interface FormField {
  key: string;
  label: string;
  /** The server's own word. Deliberately `string`, not a union — an unknown
   *  type lands on the text input, which is the safe direction: a person can
   *  always type an answer, and a control this page has never heard of would
   *  otherwise render as nothing at all. */
  type: string;
  required: boolean;
  options?: string[];
  max?: number;
}

interface FormSpec {
  title: string;
  desc: string;
  submitLabel: string;
  fields: FormField[];
  /** The honeypot's field name — planted, never shown. Server-named so the two
   *  halves cannot drift into a trap nobody checks. */
  honeypot: string;
}

type Phase = "loading" | "ready" | "sent" | "gone";

const LONG_TEXT_MIN = 0;   // every text field gets a textarea when it is the only one

export default function FormPublic({ token }: { token: string }) {
  const [spec, setSpec] = useState<FormSpec | null>(null);
  const [phase, setPhase] = useState<Phase>("loading");
  const [values, setValues] = useState<Record<string, string>>({});
  const [error, setError] = useState("");
  const [busy, setBusy] = useState(false);

  useEffect(() => {
    let live = true;
    setPhase("loading");
    fetch(`/api/v1/forms/${encodeURIComponent(token)}`)
      .then((r) => (r.ok ? r.json() : Promise.reject(new Error(String(r.status)))))
      .then((data: FormSpec) => {
        if (!live) return;
        setSpec(data);
        setPhase("ready");
      })
      // ⚠ ONE dead-end state for every failure, mirroring the server's uniform 403. Telling a
      // visitor "this form is disabled" versus "no such form" would hand an enumerator the
      // distinction the whole server-side refusal exists to withhold.
      .catch(() => live && setPhase("gone"));
    return () => {
      live = false;
    };
  }, [token]);

  const set = (key: string, v: string) =>
    setValues((prev) => ({ ...prev, [key]: v }));

  const submit = (event: React.FormEvent) => {
    event.preventDefault();
    if (busy) return;
    setBusy(true);
    setError("");
    fetch(`/api/v1/forms/${encodeURIComponent(token)}`, {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ values }),
    })
      .then(async (r) => {
        if (r.ok) {
          setPhase("sent");
          return;
        }
        // The server's sentence, verbatim — it names the field by the label THIS PAGE showed,
        // which is the only name the person has. A generic "something went wrong" here would
        // discard the one useful thing the refusal carried.
        const body = await r.json().catch(() => null);
        setError(
          String(body?.error?.message || "That could not be sent — check your answers and try again.")
        );
      })
      .catch(() =>
        setError("That could not be sent — check your connection and try again.")
      )
      .finally(() => setBusy(false));
  };

  if (phase === "loading")
    return (
      <div className="lpf-page">
        <div className="lpf-card lpf-card--quiet" />
      </div>
    );

  if (phase === "gone")
    return (
      <div className="lpf-page">
        <div className="lpf-card">
          <h1 className="lpf-title">This form is not available</h1>
          {/* ONE line (DESIGN.md §4). There is nothing this person can do about it and no
              detail we may safely give, so the page does not pretend otherwise. */}
          <p className="lpf-desc">The link may have expired or been turned off.</p>
        </div>
      </div>
    );

  if (phase === "sent")
    return (
      <div className="lpf-page">
        <div className="lpf-card">
          <h1 className="lpf-title">Thank you</h1>
          <p className="lpf-desc">Your response has been recorded.</p>
          <button
            type="button"
            className="lpf-btn"
            onClick={() => {
              setValues({});
              setPhase("ready");
            }}
          >
            Submit another
          </button>
        </div>
      </div>
    );

  if (!spec) return null;

  return (
    <div className="lpf-page">
      <form className="lpf-card" onSubmit={submit}>
        <h1 className="lpf-title">{spec.title || "Form"}</h1>
        {spec.desc && <p className="lpf-desc">{spec.desc}</p>}

        {spec.fields.map((f) => (
          <label key={f.key} className="lpf-field">
            <span className="lpf-label">
              {f.label}
              {f.required && <span className="lpf-req" aria-hidden> *</span>}
            </span>
            <FieldControl field={f} value={values[f.key] ?? ""} onChange={set} />
          </label>
        ))}

        {/* ⛔ THE HONEYPOT. Off-screen rather than `display:none` — several bot frameworks skip
            hidden inputs on purpose, and a trap they know to skip is not a trap. `tabIndex={-1}`
            and `aria-hidden` keep it away from a keyboard user and a screen reader alike, and
            `autoComplete="off"` stops a browser filling it for a real person, which would
            silently drop their submission. */}
        <input
          className="lpf-hp"
          type="text"
          name={spec.honeypot}
          value={values[spec.honeypot] ?? ""}
          onChange={(e) => set(spec.honeypot, e.target.value)}
          tabIndex={-1}
          autoComplete="off"
          aria-hidden
        />

        {error && <p className="lpf-err">{error}</p>}
        <button type="submit" className="lpf-btn lpf-btn--primary" disabled={busy}>
          {busy ? "Sending…" : spec.submitLabel || "Submit"}
        </button>
        <p className="lpf-brand">Loopable</p>
      </form>
    </div>
  );
}

/** One control per server type string. An unrecognised type falls through to text — see the
 *  `FormField.type` note: a stranger can always type an answer, and a blank where a control
 *  should be is the one failure they cannot work around. */
function FieldControl({
  field,
  value,
  onChange,
}: {
  field: FormField;
  value: string;
  onChange: (key: string, v: string) => void;
}) {
  const common = {
    className: "lpf-input",
    value,
    required: field.required,
    onChange: (e: { target: { value: string } }) => onChange(field.key, e.target.value),
  };
  switch (field.type) {
    case "checkbox":
      return (
        <input
          type="checkbox"
          className="lpf-check"
          checked={value === "1"}
          onChange={(e) => onChange(field.key, e.target.checked ? "1" : "")}
        />
      );
    case "select":
    case "status":
      return (
        <select {...common} className="lpf-input lpf-select">
          {/* An explicit empty option, always. A `<select>` with no blank member has its first
              choice pre-selected, so an untouched optional field silently submits a value the
              person never picked. */}
          <option value="">{field.required ? "Choose…" : "—"}</option>
          {(field.options ?? []).map((o) => (
            <option key={o} value={o}>{o}</option>
          ))}
        </select>
      );
    case "int":
    case "currency":
    case "pct":
    case "rating":
      return <input {...common} type="number" inputMode="decimal" />;
    case "date":
      // ⚠ `type="date"` submits ISO (`YYYY-MM-DD`) whatever the browser DISPLAYS, which is
      // exactly what the server's refuse-never-coerce date check demands. A text input here
      // would send whatever the locale suggested and be refused on half the planet.
      return <input {...common} type="date" />;
    case "email":
      return <input {...common} type="email" inputMode="email" />;
    case "phone":
      return <input {...common} type="tel" inputMode="tel" />;
    case "url":
      return <input {...common} type="url" inputMode="url" />;
    case "text":
      return (
        <textarea
          {...common}
          className="lpf-input lpf-textarea"
          rows={value.length > 60 ? 4 : 2}
          minLength={LONG_TEXT_MIN}
        />
      );
    default:
      return <input {...common} type="text" />;
  }
}
