// ---------------------------------------------------------------------------
// ui / primitives.tsx — EXIT wave 2 (W2-6, contract Y2).
//
// The shared presentation layer. Y2 fixes these names and neither side invents:
//   KpiRow · Section · DashHeader · DataTable · ClickableList · Download ·
//   ValidationPanel   (+ `fmt` next door)
//
// WHY THIS IS THE WHOLE POINT OF THE WAVE. Sales is the pilot; Collections and
// Procurement are supposed to cost "two entries in a registry" in wave 3. That
// is only true if the page renderer is a function of the Y1 envelope and
// nothing else — so no primitive here knows the word "sales", and every one of
// them takes its content from data.
//
// TWO OWNER CONSTANTS ARE ENFORCED IN CODE HERE, not left to review:
//   · **Every count drills to exact rows; a silent [:N] cap is a defect.**
//     `DataTable` renders "showing N of M" whenever the block declares a cap,
//     and it renders it from the SERVER's numbers, never from `rows.length` —
//     a client-side count of a truncated list is exactly the lie being guarded.
//   · **No emojis** — the two status glyphs are SVG (`./icons`).
//
// Colour is CSS variables only (`--lp-*`); there is not one literal in this
// file or in the `.pg-*` block of index.css. Text, strokes and rules take the
// `-deep` weight: the pastels measure 1.38–1.94:1 on white and are legitimately
// unreadable as ink ([[loopable-brand-palette]]).
// ---------------------------------------------------------------------------

import { useMemo } from "react";
import { capDisclosure, cellDescriptor, emptyText } from "./blocks";
import { BLANK, fmt, formatValue } from "./fmt";
import { AlertIcon, CheckIcon, DownloadIcon } from "./icons";
import type {
  DrillDescriptor,
  DrillRule,
  EntityKind,
  KpiItem,
  PageControl,
  PageRow,
  TableColumn,
  ValidationCheck,
} from "./types";

/** What a drill does when it fires. The shell supplies it; a primitive never
 *  decides what "open" means. */
export type OnOpen = (d: DrillDescriptor) => void;

// ------------------------------------------------------------------ DashHeader

/**
 * The page title bar: title, a hairline rule with a gold head, the subtitle,
 * and the page's controls — mirroring `ui/primitives.py:dash_header` plus the
 * BU picker that lives in the Streamlit page body.
 *
 * ⚠ `controls` render their selection from `value` IN THE RESPONSE (Y1 rule 9),
 * never from local state. A parameter the server rejected or coerced therefore
 * cannot look accepted — the picker snaps back to what the server actually did.
 */
export function DashHeader({
  title,
  asOf,
  subtitle,
  controls,
  onControl,
  busy,
}: {
  title: string;
  asOf?: string;
  subtitle?: string;
  controls?: PageControl[];
  onControl?: (key: string, value: string) => void;
  busy?: boolean;
}) {
  return (
    <header className="pg-header">
      <div className="pg-title-row">
        <h1 className="pg-title">{title}</h1>
        {controls && controls.length > 0 ? (
          <div className="pg-controls">
            {controls.map((c) => (
              <label key={c.key} className="pg-control">
                <span className="pg-control-label">{c.label}</span>
                <select
                  className="pg-select"
                  value={String(c.value)}
                  disabled={busy || !onControl}
                  onChange={(e) => onControl?.(c.key, e.target.value)}
                >
                  {/* ⚠ Every option carries an explicit `value`. A <select>
                      whose selected option omits it renders the FIRST option
                      instead — a control that silently lies about the state it
                      is displaying ([[cg-condition-builder-items]]). */}
                  {c.options.map((o) => (
                    <option key={String(o.value)} value={String(o.value)}>
                      {o.label}
                    </option>
                  ))}
                </select>
              </label>
            ))}
          </div>
        ) : null}
      </div>
      <div className="pg-rule" />
      {subtitle ? <p className="pg-subtitle">{subtitle}</p> : null}
      {asOf ? (
        <p className="pg-asof">
          Data pulled <time dateTime={asOf}>{fmt.date(asOf)}</time>
        </p>
      ) : null}
    </header>
  );
}

// --------------------------------------------------------------------- Section

/** A section header with its explanation. The prose is a `note`, shown — not a
 *  tooltip: the Streamlit version hides it behind an ⓘ to save vertical space
 *  that a real page does not have to fight for. */
export function Section({
  label,
  note,
  children,
}: {
  label: string;
  note?: string;
  children?: React.ReactNode;
}) {
  return (
    <section className="pg-section">
      <h2 className="pg-section-label">{label}</h2>
      {note ? <p className="pg-section-note">{note}</p> : null}
      {children}
    </section>
  );
}

// ---------------------------------------------------------------------- KpiRow

/**
 * The scorecard. A card with a `drill` is a real button; one without is inert
 * and must not look otherwise — a card that invites a click and does nothing is
 * the cheapest way to lose a user's trust in the whole page.
 *
 * The delta is the subtle part. Y1 rule 4: when a period has no revenue the
 * server sends `delta: null` with a `delta_label` and `delta_dir:"off"`, so the
 * card reads "no orders yet" instead of an alarming −100%. `delta_dir` tints;
 * it never replaces the sign, which comes from the number itself.
 */
export function KpiRow({ items, onOpen }: { items: KpiItem[]; onOpen?: OnOpen }) {
  if (!items.length) return null;
  return (
    <div className="pg-kpis">
      {items.map((m, i) => {
        const value = formatValue(m.value, m.fmt);
        const hasDelta = m.delta != null;
        const deltaText = hasDelta
          ? formatValue(m.delta, m.delta_fmt ?? "pct", { signed: true })
          : "";
        const dir = m.delta_dir ?? (hasDelta ? "flat" : "off");
        const body = (
          <>
            <span className="pg-kpi-label">{m.label}</span>
            <span className="pg-kpi-value">{value}</span>
            {deltaText || m.delta_label ? (
              <span className={`pg-kpi-delta is-${dir}`}>
                {deltaText}
                {deltaText && m.delta_label ? " " : ""}
                {m.delta_label ?? ""}
              </span>
            ) : null}
          </>
        );
        const key = m.key ?? `${m.label}-${i}`;
        return m.drill && onOpen ? (
          <button
            key={key}
            type="button"
            className="pg-kpi is-clickable"
            onClick={() => onOpen(m.drill as DrillDescriptor)}
          >
            {body}
          </button>
        ) : (
          <div key={key} className="pg-kpi">
            {body}
          </div>
        );
      })}
    </div>
  );
}

// -------------------------------------------------------------------- Download

/** CSV of exactly what the table is showing. Built from the same `columns` the
 *  table rendered, in the same order, with the same formatting — a download
 *  that disagrees with the screen is worse than no download.
 *
 *  ⚠ Values are formatted, then quoted. RFC-4180 quoting (double the quote,
 *  wrap anything with a comma/quote/newline) — an unquoted `$1,204` splits one
 *  money column into two. */
export function Download({
  rows,
  columns,
  filename,
  note,
}: {
  rows: PageRow[];
  columns: TableColumn[];
  filename: string;
  note?: string;
}) {
  const href = useMemo(() => {
    const esc = (s: string) =>
      /[",\r\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
    const head = columns.map((c) => esc(c.label)).join(",");
    const body = rows.map((r) =>
      columns.map((c) => esc(formatValue(r[c.key], c.fmt))).join(",")
    );
    // The BOM is what makes Excel read UTF-8 instead of the system codepage.
    const csv = "﻿" + [head, ...body].join("\r\n");
    return URL.createObjectURL(new Blob([csv], { type: "text/csv;charset=utf-8" }));
  }, [rows, columns]);

  return (
    <a className="pg-download" href={href} download={`${filename}.csv`}>
      <DownloadIcon />
      <span>Download CSV</span>
      {note ? <span className="pg-download-note">{note}</span> : null}
    </a>
  );
}

// ------------------------------------------------------------------- DataTable

/**
 * The table block.
 *
 * ⛔ THE CAP DISCLOSURE IS NOT DECORATION. `shown`/`total` come from the SERVER
 * and are rendered verbatim. Deriving "showing N of M" from `rows.length` would
 * make the disclosure agree with the truncated list by construction — it would
 * read correct on a lie. Owner rule [[no-unverifiable-aggregates]]: a number a
 * user cannot open to exact rows is a defect, and a count that cannot be wrong
 * is not a check.
 */
export function DataTable({
  columns,
  rows,
  drill,
  shown,
  total,
  onOpen,
  empty,
  download,
}: {
  columns: TableColumn[];
  rows: PageRow[];
  drill?: DrillRule;
  shown?: number;
  total?: number;
  onOpen?: OnOpen;
  empty?: string;
  download?: { filename: string };
}) {
  const cap = capDisclosure(shown, total);
  const capped = cap?.capped ?? false;

  if (!rows.length) {
    // An empty state SAYS WHAT EMPTY MEANS. "No data" is the sentence that
    // sends somebody to check whether the page is broken.
    return <p className="pg-empty">{emptyText(empty)}</p>;
  }

  return (
    <div className="pg-table-wrap">
      <table className="pg-table">
        <thead>
          <tr>
            {columns.map((c) => (
              <th
                key={c.key}
                className={c.align === "right" ? "is-right" : c.align === "center" ? "is-center" : ""}
                scope="col"
              >
                {c.label}
              </th>
            ))}
          </tr>
        </thead>
        <tbody>
          {rows.map((r, i) => {
            const d = cellDescriptor(r, drill);
            return (
              <tr key={String(r[columns[0]?.key] ?? i)} className={d && onOpen ? "is-clickable" : ""}>
                {columns.map((c, ci) => {
                  const text = formatValue(r[c.key], c.fmt);
                  const cls =
                    c.align === "right" ? "is-right" : c.align === "center" ? "is-center" : "";
                  // Only the FIRST column becomes the affordance — a whole row
                  // of links reads as eight destinations, not one.
                  return (
                    <td key={c.key} className={cls}>
                      {ci === 0 && d && onOpen ? (
                        <button type="button" className="pg-cell-link" onClick={() => onOpen(d)}>
                          {text}
                        </button>
                      ) : (
                        text
                      )}
                    </td>
                  );
                })}
              </tr>
            );
          })}
        </tbody>
      </table>
      <div className="pg-table-foot">
        {cap ? (
          <span className={cap.capped ? "pg-shown is-capped" : "pg-shown"}>{cap.text}</span>
        ) : null}
        {download ? (
          <Download
            rows={rows}
            columns={columns}
            filename={download.filename}
            note={capped ? "the rows shown" : undefined}
          />
        ) : null}
      </div>
    </div>
  );
}

// ---------------------------------------------------------------- ClickableList

/**
 * A compact list of entities, each opening its panel — the drawer affordance
 * from `ui/primitives.py:clickable_list`, for places a full table is too much.
 *
 * `kind` is the ENTITY KIND (Y6: `sku` is what Sales needs), and the emitted
 * descriptor is the same `EntityDescriptor` a table's block-level rule produces
 * — one grammar, so the shell has one handler and not one per surface.
 */
export function ClickableList({
  rows,
  kind,
  onOpen,
  idKey = "id",
  labelKey = "label",
  valueKey,
  valueFmt,
  shown,
  total,
  empty,
}: {
  rows: PageRow[];
  kind: EntityKind;
  onOpen?: OnOpen;
  idKey?: string;
  labelKey?: string;
  valueKey?: string;
  valueFmt?: string;
  shown?: number;
  total?: number;
  empty?: string;
}) {
  if (!rows.length) return <p className="pg-empty">{emptyText(empty)}</p>;
  const cap = capDisclosure(shown, total);
  return (
    <div className="pg-list">
      <ul className="pg-list-items">
        {rows.map((r, i) => {
          const id = r[idKey];
          const label = String(r[labelKey] ?? id ?? BLANK);
          const value = valueKey ? formatValue(r[valueKey], valueFmt) : null;
          const openable = onOpen && id != null && id !== "";
          return (
            <li key={String(id ?? i)} className="pg-list-item">
              {openable ? (
                <button
                  type="button"
                  className="pg-cell-link"
                  onClick={() =>
                    onOpen({ kind, id: id as string | number, label })
                  }
                >
                  {label}
                </button>
              ) : (
                <span>{label}</span>
              )}
              {value != null ? <span className="pg-list-value">{value}</span> : null}
            </li>
          );
        })}
      </ul>
      {cap ? <span className={cap.capped ? "pg-shown is-capped" : "pg-shown"}>{cap.text}</span> : null}
    </div>
  );
}

// ------------------------------------------------------------- ValidationPanel

/**
 * Every headline number reconciled to an independent aggregate — the platform's
 * standing rule ("a number that doesn't tie to Odoo does not ship") made
 * visible to the person reading the page rather than only to `validate.py`.
 *
 * A FAILING check is shown, never hidden: the panel exists to be believed, and
 * a panel that only ever shows green is decoration.
 */
export function ValidationPanel({ checks }: { checks: ValidationCheck[] }) {
  if (!checks.length) return null;
  const failed = checks.filter((c) => !c.ok).length;
  return (
    <details className="pg-validation" open={failed > 0}>
      <summary className={failed ? "is-failed" : "is-ok"}>
        {failed ? <AlertIcon /> : <CheckIcon />}
        <span>
          {failed
            ? `${fmt.int(failed)} of ${fmt.int(checks.length)} reconciliation checks did not tie`
            : `All ${fmt.int(checks.length)} reconciliation checks tie to Odoo`}
        </span>
      </summary>
      <table className="pg-table pg-validation-table">
        <thead>
          <tr>
            <th scope="col">Check</th>
            <th scope="col" className="is-right">Expected</th>
            <th scope="col" className="is-right">Actual</th>
            <th scope="col">Note</th>
          </tr>
        </thead>
        <tbody>
          {checks.map((c) => (
            <tr key={c.name} className={c.ok ? "" : "is-failed"}>
              <td>{c.name}</td>
              <td className="is-right">{formatValue(c.expected, c.fmt)}</td>
              <td className="is-right">{formatValue(c.actual, c.fmt)}</td>
              <td>{c.note ?? ""}</td>
            </tr>
          ))}
        </tbody>
      </table>
    </details>
  );
}
