/*
 * Packaging Audit UI — the main module of the app.
 *
 * Loaded as a <script type="text/babel"> tag by index.html — no bundler, matching
 * the rest of frontend-components/. Wrapped in an IIFE because Babel appends every
 * transformed file as a plain <script>, so top-level declarations here would
 * collide with those in the other modules. Exposes window.PackagingAudit and is
 * rendered inside the app shell (AppShell.jsx), so it provides no page chrome.
 *
 * Flow: upload two files -> confirm what we read -> progress -> report.
 *
 * The customer uploads their order history and their box catalog; the server
 * decides which file is which from the headers and matches them on Box ID. The
 * report shows at most three assortments — fewest sizes, the knee of the savings
 * curve, and the same count they stock today — and only the ones that actually
 * save dollars. When nothing saves, that is said outright rather than dressed up.
 */

(function () {
  const { useState, useEffect, useRef, useCallback } = React;

  const API = '';

  const {
    cn,
    Icons,
    PageContainer,
    PageHeader,
    Chip,
    TickGauge,
    BoardColumn,
    Panel,
    IconTile,
    MetricCard,
    Meter,
  } = window.Shell;

  /* ---------- formatting ---------- */

  const money = (cents, digits = 0) =>
    `${cents < 0 ? '-' : ''}$${Math.abs(cents / 100).toLocaleString('en-US', {
      minimumFractionDigits: digits,
      maximumFractionDigits: digits,
    })}`;
  const money2 = cents => money(cents, 2);
  const num = (n, digits = 0) =>
    Number(n || 0).toLocaleString('en-US', {
      minimumFractionDigits: digits,
      maximumFractionDigits: digits,
    });
  const pct = n => `${Number(n || 0).toFixed(2)}%`;
  const dims = b => `${b.lengthIn} × ${b.widthIn} × ${b.heightIn} in`;

  /* ---------- the three report slots ---------- */

  /**
   * The scenarios the engine can offer, in the order they are presented.
   *
   * Only slots that actually saved money are rendered. An option that costs more
   * than what the warehouse runs today is not an option, so showing an empty
   * column for it would just be a reader's dead end.
   */
  const SLOTS = [
    {
      slot: 'minimum',
      title: 'Fewest sizes',
      blurb: 'The shortest list of box sizes that still fits every order.',
    },
    {
      slot: 'medium',
      title: 'Best value per size',
      blurb: 'The point where adding another size stops being worth it.',
    },
    {
      slot: 'equal',
      title: 'Same count as today',
      blurb: 'Keep stocking the same number of sizes — just different ones.',
    },
  ];

  const SLOT_TITLE = SLOTS.reduce((acc, s) => Object.assign(acc, { [s.slot]: s.title }), {});

  const ROLE_TITLE = {
    orders: 'Order history',
    boxes: 'Box catalog',
    combined: 'Combined file',
  };

  const TRANSPORT_SOURCE_NOTE = {
    orders: 'Per shipment, from your order file',
    boxes: 'Flat per box type, from your box file',
    none: 'Not provided — carton savings only',
  };

  /* ---------- small building blocks ---------- */

  /** Alert box, in the three tones the flow can produce. */
  function Notice({ tone = 'error', children }) {
    const cls =
      tone === 'warn'
        ? 'border-warning/30 bg-warning/10 text-warning'
        : tone === 'info'
          ? 'border-border bg-card-raised text-muted-foreground'
          : 'border-danger/30 bg-danger/10 text-danger';
    return <div className={`rounded-2xl border px-4 py-3 text-sm ${cls}`}>{children}</div>;
  }

  function Section({ title, subtitle, children, right }) {
    return (
      <section className="mt-8">
        <div className="flex flex-wrap items-end justify-between gap-3">
          <div>
            <h2 className="text-base font-semibold text-foreground">{title}</h2>
            {subtitle && <p className="mt-0.5 text-sm text-muted-foreground">{subtitle}</p>}
          </div>
          {right}
        </div>
        <div className="mt-3">{children}</div>
      </section>
    );
  }

  /** `flush` drops the body padding and prose styling, for embedding a table. */
  function Disclosure({ title, children, defaultOpen = false, flush = false }) {
    const [open, setOpen] = useState(defaultOpen);
    return (
      <div className="rounded-2xl border border-border bg-card">
        <button
          onClick={() => setOpen(o => !o)}
          className="flex w-full items-center justify-between px-4 py-3 text-left text-sm font-medium text-foreground hover:bg-card-raised/60"
        >
          {title}
          <span className="text-muted-foreground">{open ? '−' : '+'}</span>
        </button>
        {open && (
          <div
            className={
              flush
                ? 'overflow-hidden rounded-b-2xl border-t border-border'
                : 'border-t border-border px-4 py-3 text-sm leading-relaxed text-muted-foreground'
            }
          >
            {children}
          </div>
        )}
      </div>
    );
  }

  function Table({ headers, rows, align = {} }) {
    return (
      <div className="overflow-x-auto rounded-2xl border border-border bg-card">
        <table className="min-w-full text-sm">
          <thead className="bg-muted text-2xs uppercase tracking-widest text-muted-foreground">
            <tr>
              {headers.map((h, i) => (
                <th
                  key={i}
                  className={`whitespace-nowrap px-3 py-2.5 ${align[i] === 'r' ? 'text-right' : 'text-left'}`}
                >
                  {h}
                </th>
              ))}
            </tr>
          </thead>
          <tbody className="divide-y divide-border text-foreground">
            {rows.map((row, r) => (
              <tr key={r} className="hover:bg-muted/60">
                {row.map((cell, c) => (
                  <td
                    key={c}
                    className={`whitespace-nowrap px-3 py-2 ${align[c] === 'r' ? 'text-right tabular-nums' : ''}`}
                  >
                    {cell}
                  </td>
                ))}
              </tr>
            ))}
          </tbody>
        </table>
      </div>
    );
  }

  /* ---------- visuals ---------- */

  /**
   * A 2D carton, seen from the side.
   *
   * Detailed enough to read as a real parcel at this size: corrugated flutes, two
   * lid flaps meeting in the middle with tape over the join, a shipping label, and
   * the goods inside drawn as a filled block. `fill` is what carries the pitch —
   * the gap above the block is the space being paid for and not used.
   */
  function Carton({ w, h, fill, accent, label = true, strap = false }) {
    const lid = 28; // percent of the height taken by the flaps
    return (
      <div className="relative" style={{ width: `${w}px`, height: `${h}px` }}>
        {/* Contact shadow, so the carton sits on the belt rather than hovers. Light
            enough to stay a shadow on a white belt instead of a black smudge. */}
        <div className="absolute -bottom-[5px] left-[6%] right-[6%] h-[9px] rounded-[50%] bg-foreground/20 blur-[4px]" />

        <div className="absolute inset-0 overflow-hidden rounded-[6px] border border-foreground/30 bg-card-raised">
          {/* Corrugation. Barely visible on purpose — it only has to suggest board. */}
          <div
            className="absolute inset-0"
            style={{
              backgroundImage:
                'repeating-linear-gradient(90deg, hsl(var(--foreground) / 0.055) 0 1px, transparent 1px 7px)',
            }}
          />

          {/* Goods. */}
          <div
            className={cn(
              'absolute inset-x-[8%] bottom-[7%] rounded-[3px]',
              accent ? 'bg-primary/85' : 'bg-foreground/45'
            )}
            style={{ height: `${Math.round(fill * (100 - lid - 10))}%` }}
          >
            <div className="absolute inset-x-[12%] top-[18%] h-[1px] bg-background/25" />
          </div>

          {/* Two lid flaps, the seam between them, and tape across it. */}
          <div
            className="absolute inset-x-0 top-0 bg-foreground/[0.07]"
            style={{ height: `${lid}%` }}
          />
          <div
            className="absolute inset-x-0 border-b border-foreground/25"
            style={{ top: `${lid}%` }}
          />
          <div
            className="absolute left-1/2 top-0 w-[2px] -translate-x-1/2 bg-foreground/25"
            style={{ height: `${lid}%` }}
          />
          <div
            className="absolute left-1/2 top-0 w-[16%] min-w-[8px] -translate-x-1/2 bg-foreground/20"
            style={{ height: `${lid}%` }}
          />

          {/* Shipping label: two lines of address over a barcode. */}
          {label && (
            <div
              className="absolute right-[9%] rounded-[2px] bg-foreground/55 p-[3px]"
              style={{ top: `${lid + 12}%`, width: '28%', height: '26%' }}
            >
              <div className="h-[2px] w-full rounded-full bg-background/60" />
              <div className="mt-[2px] h-[2px] w-2/3 rounded-full bg-background/60" />
              <div
                className="mt-[3px] h-[6px] w-full"
                style={{
                  backgroundImage:
                    'repeating-linear-gradient(90deg, hsl(var(--background) / 0.85) 0 1px, transparent 1px 3px)',
                }}
              />
            </div>
          )}

          {/* A strapped parcel, for variety in the queue. */}
          {strap && (
            <div className="absolute inset-y-0 left-[22%] w-[5px] bg-foreground/15 shadow-[1px_0_0_hsl(var(--foreground)/0.08)]" />
          )}
        </div>
      </div>
    );
  }

  /**
   * Packages riding a belt, looping.
   *
   * Decorative, so it is hidden from assistive tech and stops entirely under
   * prefers-reduced-motion (see the keyframes in index.html). No frame around it —
   * the belt runs across the page above the headline, and the gradients at the ends
   * are what carry parcels on and off rather than a border.
   */
  function ConveyorBelt() {
    const RIDERS = [
      { at: 1, w: 96, h: 76, fill: 0.28, delay: 0, strap: true },
      { at: 21, w: 60, h: 48, fill: 0.86, delay: -2.8, accent: true },
      { at: 40, w: 80, h: 62, fill: 0.44, delay: -5.6 },
      { at: 60, w: 52, h: 42, fill: 0.9, delay: -8.4, label: false },
      { at: 80, w: 116, h: 90, fill: 0.24, delay: -11.2 },
    ];
    return (
      <div aria-hidden="true" className="relative h-44 overflow-hidden">
        {RIDERS.map(r => (
          <div
            key={r.at}
            className="arka-rider absolute bottom-[62px]"
            style={{ left: `${r.at}%`, animationDelay: `${r.delay}s` }}
          >
            <div className="arka-bob" style={{ animationDelay: `${r.delay / 2}s` }}>
              <Carton
                w={r.w}
                h={r.h}
                fill={r.fill}
                accent={r.accent}
                strap={r.strap}
                label={r.label !== false}
              />
            </div>
          </div>
        ))}

        {/* Belt surface, the returning underside, the rollers, and the legs. The
            machinery is drawn in foreground alphas rather than the border token,
            which is a hairline meant to sit next to a fill — too faint to read as
            steel once the surface behind it is white. */}
        <div className="arka-tread absolute inset-x-0 bottom-[46px] h-[16px] border-y border-foreground/20 bg-card-raised" />
        <div className="absolute inset-x-0 bottom-[30px] h-[6px] bg-card" />
        <div className="absolute inset-x-4 bottom-[22px] flex justify-between">
          {Array.from({ length: 22 }).map((_, i) => (
            <span
              key={i}
              className="h-[18px] w-[18px] rounded-full border border-foreground/20 bg-card"
            />
          ))}
        </div>
        <div className="absolute inset-x-0 bottom-[20px] h-[2px] bg-foreground/15" />
        {[14, 46, 78].map(x => (
          <div
            key={x}
            className="absolute bottom-0 h-[20px] w-[5px] bg-foreground/20"
            style={{ left: `${x}%` }}
          />
        ))}

        {/* Packages enter and leave the frame rather than popping in at the edge. */}
        <div className="pointer-events-none absolute inset-y-0 left-0 w-32 bg-gradient-to-r from-background via-background/80 to-transparent" />
        <div className="pointer-events-none absolute inset-y-0 right-0 w-32 bg-gradient-to-l from-background via-background/80 to-transparent" />
      </div>
    );
  }

  /**
   * The two halves of a bill as a ring, with each slice priced beneath it.
   *
   * A ring rather than a bar because the question here is only what the total
   * divides into, and a ring says "share of one whole" without the reader having
   * to compare lengths. The total sits in the middle: two rings of the same
   * diameter would otherwise imply the bill itself never moved.
   *
   * The centre figure is a positioned DOM element rather than an SVG <text>, for
   * the same reason Sparkline's end-point marker is — text inside a viewBox is
   * scaled by the viewBox and stops matching the type around it.
   */
  const DONUT_TONE = {
    solid: 'text-foreground/70',
    muted: 'text-foreground/35',
    faint: 'text-foreground/12',
  };

  function CostDonut({ label, sublabel, segments, size = 176 }) {
    const total = segments.reduce((sum, s) => sum + Math.max(0, s.value), 0);
    const span = total || 1;
    // Ring centre-line radius 54 with a 20-unit stroke puts the outer edge on the
    // 128-unit viewBox exactly, so nothing clips at any rendered size.
    const R = 54;
    const CIRC = 2 * Math.PI * R;
    let offset = 0;

    return (
      <div className="flex flex-col items-center">
        <div className="relative shrink-0" style={{ width: size, height: size }}>
          <svg viewBox="0 0 128 128" className="h-full w-full">
            {/* Rotated so the first slice starts at twelve o'clock. */}
            <g transform="rotate(-90 64 64)">
              {segments.map(s => {
                const len = (Math.max(0, s.value) / span) * CIRC;
                const arc = (
                  <circle
                    key={s.label}
                    cx="64"
                    cy="64"
                    r={R}
                    fill="none"
                    stroke="currentColor"
                    strokeWidth="20"
                    strokeDasharray={`${len} ${CIRC - len}`}
                    strokeDashoffset={-offset}
                    className={DONUT_TONE[s.tone] || DONUT_TONE.muted}
                  />
                );
                offset += len;
                return arc;
              })}
            </g>
          </svg>
          <div className="absolute inset-0 flex flex-col items-center justify-center">
            <div className="text-xl font-semibold leading-none tabular-nums text-foreground">
              {money(total)}
            </div>
            <div className="mt-1 text-2xs text-muted-foreground">{label}</div>
          </div>
        </div>
        {sublabel && <div className="mt-2 text-2xs text-muted-foreground">{sublabel}</div>}
        <dl className="mt-3 w-full max-w-xs space-y-1.5">
          {segments.map(s => (
            <div key={s.label} className="flex items-center gap-2 text-xs">
              <span
                className={cn(
                  'h-2.5 w-2.5 shrink-0 rounded-sm bg-current',
                  DONUT_TONE[s.tone] || DONUT_TONE.muted
                )}
              />
              <dt className="min-w-0 flex-1 truncate text-muted-foreground">{s.label}</dt>
              <dd className="shrink-0 tabular-nums text-foreground">{money(s.value)}</dd>
              <dd className="w-14 shrink-0 text-right tabular-nums text-muted-foreground">
                {pct((Math.max(0, s.value) / span) * 100)}
              </dd>
            </div>
          ))}
        </dl>
      </div>
    );
  }

  /** The two cost lines of a bill, in the order they are always drawn. */
  function costSegments(row) {
    return [
      { label: 'Transportation costs', value: row.totalTransportCents, tone: 'solid' },
      { label: 'Box costs', value: row.totalBoxCents, tone: 'muted' },
    ];
  }

  /**
   * The saving as a slice of what was actually spent.
   *
   * Two bars drawn to scale can look identical, because a saving is often a few
   * percent of the bill. So the bill is drawn once and the saving is the piece of
   * it that comes back — still proportional, but the sliver is the point rather
   * than something the reader has to spot.
   */
  function SavingSplit({ paid, saved, invert }) {
    const total = Math.max(1, paid);
    const savedPct = Math.min(100, Math.max(0, (Math.max(0, saved) / total) * 100));
    const keep = Math.max(0, paid - saved);
    return (
      <div>
        <div
          className={cn(
            'flex h-3 w-full overflow-hidden rounded-full',
            invert ? 'bg-background/20' : 'bg-card-raised'
          )}
        >
          <div
            className={invert ? 'bg-background/40' : 'bg-foreground/25'}
            style={{ width: `${100 - savedPct}%` }}
          />
          {/* A saving can be a couple of percent of the bill, so the slice is
              given a floor of a few pixels — otherwise the one thing the card is
              about would be too thin to see. */}
          <div
            className={invert ? 'bg-background' : 'bg-primary'}
            style={{ width: `${savedPct}%`, minWidth: '4px' }}
          />
        </div>
        <div className="mt-2 flex items-baseline justify-between gap-3 text-2xs">
          <span className={invert ? 'text-background/70' : 'text-muted-foreground'}>
            <span
              className={cn(
                'font-semibold tabular-nums',
                invert ? 'text-background' : 'text-foreground'
              )}
            >
              {money(keep)}
            </span>{' '}
            still spent
          </span>
          <span className={invert ? 'text-background/70' : 'text-muted-foreground'}>
            <span
              className={cn(
                'font-semibold tabular-nums',
                invert ? 'text-background' : 'text-primary-ink'
              )}
            >
              {money(saved)}
            </span>{' '}
            back in your pocket
          </span>
        </div>
      </div>
    );
  }

  /**
   * Every box in a lineup drawn to scale, footprint on, largest first.
   *
   * The table below this says 13 × 10 × 8.5 in; this says how big that actually is
   * next to the others, which is the thing a reader cannot get from a number.
   */
  function BoxLadder({ boxes, maxSide = 92 }) {
    const longest = Math.max(...boxes.flatMap(b => [b.lengthIn, b.widthIn]), 1);
    const scale = maxSide / longest;
    return (
      <div className="flex flex-wrap items-end gap-x-6 gap-y-5">
        {[...boxes]
          .sort((a, b) => b.volumeIn3 - a.volumeIn3)
          .map(b => (
            <div key={b.id} className="flex flex-col items-center gap-2">
              <div
                className={cn(
                  'relative rounded border',
                  b.isIncumbent
                    ? 'border-border bg-card-raised'
                    : 'border-primary/50 bg-primary/15'
                )}
                style={{
                  width: `${Math.max(10, b.lengthIn * scale)}px`,
                  height: `${Math.max(10, b.widthIn * scale)}px`,
                }}
                title={`${b.id}: ${dims(b)}`}
              >
                {/* Height cannot be drawn on a footprint, so it is stamped on it. */}
                <span className="absolute inset-0 flex items-center justify-center text-2xs tabular-nums text-muted-foreground">
                  {b.heightIn}″
                </span>
              </div>
              <div className="text-center">
                <div className="text-2xs font-semibold text-foreground">{b.id}</div>
                <div className="text-2xs tabular-nums text-muted-foreground">{dims(b)}</div>
                <div
                  className={cn(
                    'text-2xs',
                    b.isIncumbent ? 'text-muted-foreground' : 'text-primary-ink'
                  )}
                >
                  {b.isIncumbent ? 'you stock it' : 'new size'}
                </div>
              </div>
            </div>
          ))}
      </div>
    );
  }

  /** Label / value pair, for the small diagnostic grids. */
  function Fact({ label, value, tone }) {
    return (
      <div className="rounded-xl bg-card-raised px-3 py-2">
        <div className="text-2xs text-muted-foreground">{label}</div>
        <div
          className={cn(
            'text-sm font-medium tabular-nums',
            tone === 'bad' ? 'text-danger' : tone === 'good' ? 'text-success' : 'text-foreground'
          )}
        >
          {value}
        </div>
      </div>
    );
  }

  /* ---------- schema helpers ---------- */

  /** Both identifiers stand in for each other, so the requirement is "one of". */
  function missingForRole(role, fields, mapping) {
    const missing = fields.filter(f => f.required && !mapping[f.field]).map(f => f.label);
    if (role !== 'boxes' && !mapping.shipmentId && !mapping.orderId) {
      missing.push('Order ID or Shipment ID');
    }
    return missing;
  }

  /* ---------- step 1: upload ---------- */

  /**
   * Contents of the generated dataset behind "Try it with sample data".
   *
   * These are the generator's own parameters (api/src/audit/sampleData.ts), not
   * results — nothing here can drift out of step with a change to the engine.
   */
  const SAMPLE_SPEC = [
    ['Parcels shipped', '1,200'],
    ['Customer orders', '~1,035'],
    ['SKUs in the catalog', '45'],
    ['Carton sizes stocked', '6 cubes'],
    ['Transportation billed on', 'dimensional weight'],
  ];

  function FileChip({ file, onRemove }) {
    return (
      <span className="inline-flex max-w-full items-center gap-2 rounded-full border border-border bg-card-raised px-3 py-1.5 text-xs text-foreground">
        <Icons.File className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
        <span className="truncate">{file.name}</span>
        <span className="shrink-0 tabular-nums text-muted-foreground">
          {(file.size / 1024 / 1024).toFixed(2)} MB
        </span>
        <button
          onClick={onRemove}
          aria-label={`Remove ${file.name}`}
          className="shrink-0 text-muted-foreground transition-colors hover:text-foreground"
        >
          <Icons.Close className="h-3.5 w-3.5" />
        </button>
      </span>
    );
  }

  /** One row of the column checklist: the name, and why it is there. */
  function FieldRow({ label, note }) {
    return (
      <li className="flex items-start gap-2.5">
        <span className="mt-[7px] h-1.5 w-1.5 shrink-0 rounded-full bg-primary" />
        <span className="text-sm text-foreground">
          {label}
          {note && <span className="text-xs text-muted-foreground"> — {note}</span>}
        </span>
      </li>
    );
  }

  /**
   * One of the two files, as a checklist of the columns it has to carry.
   *
   * Every column in the schema is mandatory, so there is no "optional" section:
   * the only distinction the list draws is the Order ID / Shipment ID pair, where
   * either one satisfies the requirement and the two collapse into a single row.
   */
  function FileFieldsCard({ group, index }) {
    const bucket = f => f.requirement || (f.required ? 'required' : 'optional');
    // Schema order is the order of the columns in a real export, so keep it. The
    // "either" fields collapse into one row where the first of them sits.
    const either = group.fields.filter(f => bucket(f) === 'either');
    const rows = [];
    group.fields.forEach(f => {
      if (bucket(f) !== 'either') {
        rows.push({ key: f.field, label: f.label, note: f.note });
      } else if (f === either[0]) {
        rows.push({
          key: 'either',
          label: either.map(e => e.label).join(' or '),
          note: 'either one is enough, both is better',
        });
      }
    });

    return (
      <div className="flex flex-col rounded-2xl border border-border bg-card p-5">
        <div className="flex items-center gap-3">
          <IconTile icon={index === 0 ? Icons.Layers : Icons.Box} />
          <div className="min-w-0">
            <div className="text-sm font-semibold text-foreground">{group.title}</div>
            <div className="text-2xs text-muted-foreground">{group.grain}</div>
          </div>
          <span className="ml-auto shrink-0 rounded-full border border-border px-2.5 py-1 text-2xs font-medium text-muted-foreground">
            File {index + 1}
          </span>
        </div>

        <div className="mt-5 text-2xs font-bold uppercase tracking-widest text-primary-ink">
          Columns we need
        </div>
        <ul className="mt-2.5 space-y-1.5">
          {rows.map(r => (
            <FieldRow key={r.key} label={r.label} note={r.note} />
          ))}
        </ul>
      </div>
    );
  }

  function UploadStep({ onPreview, busy, error }) {
    const [groups, setGroups] = useState([]);
    const [files, setFiles] = useState([]);
    const [dragging, setDragging] = useState(false);
    const inputRef = useRef(null);

    useEffect(() => {
      fetch(`${API}/api/audit/schema`)
        .then(r => r.json())
        .then(d => setGroups(d.files || []))
        .catch(() => setGroups([]));
    }, []);

    const addFiles = incoming => {
      const list = Array.from(incoming || []);
      if (list.length === 0) return;
      setFiles(prev => [...prev, ...list].slice(0, 2));
    };

    const trySample = async () => {
      const [orders, boxes] = await Promise.all([
        fetch(`${API}/api/audit/sample-orders.csv?shipments=1200`).then(r => r.blob()),
        fetch(`${API}/api/audit/sample-boxes.csv?shipments=1200`).then(r => r.blob()),
      ]);
      // Sent unlabelled, exactly like a customer's own upload, so the demo runs
      // the same detection path the real thing does.
      onPreview([
        new File([orders], 'sample-orders.csv', { type: 'text/csv' }),
        new File([boxes], 'sample-boxes.csv', { type: 'text/csv' }),
      ]);
    };

    return (
      <div className="space-y-5">
        <ConveyorBelt />

        {/* Greeting-scale headline rather than a page title: this is the first
            thing a visitor sees and it has to say what the app is for. */}
        <div className="max-w-3xl">
          <div className="text-2xs font-bold uppercase tracking-widest text-primary-ink">
            Optimization analysis
          </div>
          <h1 className="mt-2 text-3xl font-semibold leading-tight tracking-tight text-foreground sm:text-4xl">
            Let us help you ship <span className="text-muted-foreground">less air</span>.
          </h1>
          {/* One idea per line, each short enough to stay on a single line at this
              width: a four-line read is what a first-time visitor will actually
              finish, where the same words as a paragraph get skipped. */}
          <div className="mt-4 space-y-2 text-base leading-relaxed text-foreground/85">
            <p>Upload two files: the orders you shipped, and the boxes you shipped them in.</p>
            <p>We price what each of those shipments cost you — carton plus transportation.</p>
            <p>Then we design smaller boxes that hold the same orders.</p>
            <p>You get back what each new lineup would have saved you, in dollars.</p>
          </div>
        </div>

        {error && <Notice>{error}</Notice>}

        {/* What the files need to contain, stated before the dropzone rather than
            after it — it is what you have to know in order to use it. A checklist
            of columns rather than a sentence, because the reader is about to go
            and look for these names in their own export. */}
        <div className="grid gap-4 lg:grid-cols-2">
          {groups.map((group, i) => (
            <FileFieldsCard key={group.role} group={group} index={i} />
          ))}
        </div>

        {/* Two equal panels, because these are two equally valid ways to start:
            your own data, or a warehouse we generated. */}
        <div className="grid items-stretch gap-5 lg:grid-cols-2">
          {/* One dropzone for both files rather than two labelled slots: which
              file is which is read from the headers and shown for confirmation on
              the next screen, so there is nothing here to get wrong. */}
          <div
            onDragOver={e => {
              e.preventDefault();
              setDragging(true);
            }}
            onDragLeave={() => setDragging(false)}
            onDrop={e => {
              e.preventDefault();
              setDragging(false);
              addFiles(e.dataTransfer.files);
            }}
            className={cn(
              'flex h-full flex-col rounded-2xl border p-5 transition-colors',
              dragging ? 'border-primary bg-primary/5' : 'border-border bg-card'
            )}
          >
            <div className="mb-4">
              <div className="text-2xs font-bold uppercase tracking-widest text-muted-foreground">
                Your data
              </div>
              <h3 className="mt-1 text-sm font-semibold text-foreground">Upload the two files</h3>
              <p className="mt-1 text-xs leading-relaxed text-muted-foreground">
                CSV, XLSX or XLSB. Column names are matched for you and shown to confirm before
                anything runs.
              </p>
            </div>

            <div
              className={cn(
                'flex flex-1 flex-col items-center justify-center rounded-xl border border-dashed px-6 py-10 text-center transition-colors',
                dragging ? 'border-primary bg-primary/5' : 'border-border bg-card-raised/40'
              )}
            >
              <IconTile icon={Icons.Upload} size="lg" />
              <p className="mt-4 text-sm font-medium text-foreground">Drop both files here</p>
              <p className="mt-1 text-xs text-muted-foreground">
                Order history and box catalog, in either order
              </p>

              {files.length > 0 && (
                <div className="mt-4 flex w-full flex-wrap justify-center gap-2">
                  {files.map((f, i) => (
                    <FileChip
                      key={`${f.name}-${i}`}
                      file={f}
                      onRemove={() => setFiles(prev => prev.filter((_, j) => j !== i))}
                    />
                  ))}
                </div>
              )}

              <div className="mt-5 flex flex-wrap items-center justify-center gap-2">
                <button
                  onClick={() => inputRef.current?.click()}
                  disabled={busy || files.length >= 2}
                  className="rounded-full border border-border px-4 py-2.5 text-sm font-medium text-foreground transition-colors hover:bg-card-raised disabled:opacity-50"
                >
                  {files.length === 0 ? 'Choose files' : 'Add another'}
                </button>
                <button
                  onClick={() => onPreview(files)}
                  disabled={busy || files.length === 0}
                  className="rounded-full bg-primary px-5 py-2.5 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 disabled:opacity-50"
                >
                  {busy ? 'Reading…' : 'Read the files'}
                </button>
              </div>
              <input
                ref={inputRef}
                type="file"
                multiple
                accept=".csv,.xlsx,.xls,.xlsb,.txt"
                className="hidden"
                onChange={e => {
                  addFiles(e.target.files);
                  e.target.value = '';
                }}
              />
            </div>

            {/* The templates belong here, next to the ask: they are the answer to
                "what is this supposed to look like?", which is a question you only
                have while staring at the dropzone. */}
            <div className="mt-4 flex flex-wrap items-center gap-2 border-t border-border pt-4">
              <span className="text-xs text-muted-foreground">Not sure of the format?</span>
              <a
                href={`${API}/api/audit/template-orders.csv`}
                className="inline-flex items-center gap-1.5 rounded-full border border-border px-3 py-1.5 text-xs font-medium text-foreground transition-colors hover:bg-card-raised"
              >
                <Icons.Download className="h-3.5 w-3.5 text-muted-foreground" />
                Order history template
              </a>
              <a
                href={`${API}/api/audit/template-boxes.csv`}
                className="inline-flex items-center gap-1.5 rounded-full border border-border px-3 py-1.5 text-xs font-medium text-foreground transition-colors hover:bg-card-raised"
              >
                <Icons.Download className="h-3.5 w-3.5 text-muted-foreground" />
                Box catalog template
              </a>
            </div>
          </div>

          {/* Tinted rather than filled: at this size a solid accent block would
              shout down the upload card next to it, when the point is that the two
              routes in are equally available. The green is spent on the button. */}
          <div className="flex h-full flex-col rounded-2xl border border-primary/25 bg-gradient-to-br from-primary/10 via-card to-card p-5">
            <div className="mb-4">
              <div className="text-2xs font-bold uppercase tracking-widest text-primary-ink">
                No data handy?
              </div>
              <h3 className="mt-1 text-sm font-semibold text-foreground">
                Run it on a sample warehouse
              </h3>
              <p className="mt-1 text-xs leading-relaxed text-muted-foreground">
                A small ecommerce warehouse shipping flat, light goods — apparel, books,
                accessories — out of nothing but square cartons, on transportation billed by
                dimensional weight. Same two files and the same columns as above, written for
                you on the spot — nothing of yours is uploaded, and nothing is stored.
              </p>
            </div>

            {/* What the sample contains, so the button is not a leap of faith. */}
            <dl className="space-y-1.5">
              {SAMPLE_SPEC.map(([k, v]) => (
                <div
                  key={k}
                  className="flex items-baseline justify-between gap-3 rounded-lg bg-card-raised/70 px-3 py-2"
                >
                  <dt className="text-xs text-muted-foreground">{k}</dt>
                  <dd className="text-sm font-semibold tabular-nums text-foreground">{v}</dd>
                </div>
              ))}
            </dl>
            {/* The dataset is tuned so all three slots pay off — a demo that showed
                one option would not show what the report can do. */}
            <p className="mt-3 text-xs leading-relaxed text-muted-foreground">
              It has enough waste in it to fill all three options: the fewest carton sizes that
              still save, the best value per size, and the same count stocked today.
            </p>
            <div className="mt-auto pt-4">
              <button
                onClick={trySample}
                disabled={busy}
                className="inline-flex w-full items-center justify-center gap-2 rounded-full border border-primary/50 px-4 py-2.5 text-sm font-medium text-primary-ink transition-colors hover:bg-primary/10 disabled:opacity-50"
              >
                Try it with sample data
                <Icons.ArrowUpRight className="h-4 w-4" />
              </button>
            </div>
          </div>
        </div>
      </div>
    );
  }

  /* ---------- step 2: confirm what we read ---------- */

  /** How the two files were matched, and where transportation costs came from. */
  function JoinPanel({ join }) {
    const dropped = join.droppedRows > 0;
    return (
      <Panel
        title="Box ID match"
        subtitle="Box ID is the only column that has to appear in both files — it is what ties an order to the box it shipped in."
        actions={
          <Chip icon={Icons.Truck} tone={join.transportSource === 'none' ? 'bad' : undefined}>
            {TRANSPORT_SOURCE_NOTE[join.transportSource]}
          </Chip>
        }
      >
        <div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-4">
          <Fact label="Box types in catalog" value={num(join.boxIdsInBoxFile)} />
          <Fact
            label="Matched"
            value={`${num(join.matchedBoxIds)} of ${num(join.boxIdsInOrderFile)}`}
            tone={join.matchedBoxIds < join.boxIdsInOrderFile ? 'bad' : 'good'}
          />
          <Fact label="Rows matched" value={pct(join.matchRatePercent)} />
          <Fact
            label="Rows dropped"
            value={num(join.droppedRows)}
            tone={dropped ? 'bad' : undefined}
          />
        </div>
        {join.unmatchedOrderBoxIds.length > 0 && (
          <p className="mt-3 text-xs leading-relaxed text-muted-foreground">
            Not in your catalog, so those rows are left out:{' '}
            <span className="text-foreground">{join.unmatchedOrderBoxIds.join(', ')}</span>
          </p>
        )}
        {join.unusedCatalogBoxIds.length > 0 && (
          <p className="mt-1.5 text-xs leading-relaxed text-muted-foreground">
            Never used by an order row:{' '}
            <span className="text-foreground">{join.unusedCatalogBoxIds.join(', ')}</span>
          </p>
        )}
        {join.duplicateCatalogRows > 0 && (
          <p className="mt-1.5 text-xs leading-relaxed text-muted-foreground">
            {num(join.duplicateCatalogRows)} duplicate catalog row(s) were collapsed by Box ID
            using the median of each column.
          </p>
        )}
      </Panel>
    );
  }

  /** Column mapping for one uploaded file, editable. */
  function MappingCard({ file, fields, mapping, onChange, onSwap }) {
    const missing = missingForRole(file.role, fields, mapping);
    return (
      <Panel
        eyebrow={ROLE_TITLE[file.role]}
        title={file.fileName}
        subtitle={`${num(file.usableRows)} usable rows of ${num(file.totalRows)}${
          file.skippedRows ? ` — ${num(file.skippedRows)} skipped` : ''
        }`}
        actions={
          onSwap && (
            <button
              onClick={onSwap}
              className="rounded-full border border-border px-3 py-1.5 text-xs font-medium text-foreground transition-colors hover:bg-card-raised"
            >
              This is the other file
            </button>
          )
        }
      >
        <div className="divide-y divide-border overflow-hidden rounded-xl border border-border">
          {fields.map(f => (
            <div
              key={f.field}
              className="flex flex-wrap items-center justify-between gap-3 bg-card-raised/40 px-3 py-2"
            >
              <div className="text-sm text-foreground">
                {f.label}
                {f.required && <span className="ml-1 text-danger">*</span>}
              </div>
              <select
                value={mapping[f.field] || ''}
                onChange={e => onChange({ ...mapping, [f.field]: e.target.value || undefined })}
                className="w-56 rounded-lg border border-border bg-card px-2 py-1.5 text-sm text-foreground focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary"
              >
                <option value="">— not mapped —</option>
                {(file.headers || []).map(h => (
                  <option key={h} value={h}>
                    {h}
                  </option>
                ))}
              </select>
            </div>
          ))}
        </div>
        {missing.length > 0 && (
          <p className="mt-3 text-xs text-danger">Still needed: {missing.join(', ')}</p>
        )}
      </Panel>
    );
  }

  function MappingStep({ preview, schema, onRun, onBack, onSwap, busy }) {
    const validation = preview.validation;
    const [mappings, setMappings] = useState(() =>
      validation.files.map(f => Object.assign({}, f.mapping))
    );

    // A swap re-previews from scratch, so the detected mappings change under us.
    useEffect(() => {
      setMappings(validation.files.map(f => Object.assign({}, f.mapping)));
    }, [validation]);

    const fieldsFor = role => {
      if (role === 'combined') return schema.fields || [];
      const group = (schema.files || []).find(g => g.role === role);
      return group ? group.fields : [];
    };

    const missing = validation.files.flatMap((f, i) =>
      missingForRole(f.role, fieldsFor(f.role), mappings[i] || {})
    );

    return (
      <div className="mx-auto max-w-4xl">
        <PageHeader
          title="Check what we read"
          description={
            preview.mode === 'pair'
              ? `${num(validation.usableRows)} order rows matched to ${num(
                  validation.incumbentBoxCount || validation.join?.matchedBoxIds || 0
                )} box types.${preview.autoDetected ? ' File roles were detected from the headers.' : ''}`
              : `${validation.fileName} — ${num(validation.usableRows)} usable rows of ${num(
                  validation.totalRows
                )}.`
          }
        />

        {validation.join && (
          <div className="mb-5">
            <JoinPanel join={validation.join} />
          </div>
        )}

        {validation.warnings?.length > 0 && (
          <div className="mb-5 space-y-2">
            {validation.warnings.map((w, i) => (
              <Notice key={i} tone="warn">
                {w}
              </Notice>
            ))}
          </div>
        )}

        <div className="space-y-5">
          {validation.files.map((f, i) => (
            <MappingCard
              key={`${f.role}-${f.fileName}`}
              file={f}
              fields={fieldsFor(f.role)}
              mapping={mappings[i] || {}}
              onChange={next => setMappings(prev => prev.map((m, j) => (j === i ? next : m)))}
              onSwap={preview.mode === 'pair' ? onSwap : null}
            />
          ))}
        </div>

        {missing.length > 0 && (
          <div className="mt-5">
            <Notice>Still needed: {Array.from(new Set(missing)).join(', ')}</Notice>
          </div>
        )}

        <div className="mt-6 flex gap-3">
          <button
            onClick={onBack}
            className="rounded-full border border-border px-4 py-2 text-sm font-medium text-foreground transition-colors hover:bg-card-raised"
          >
            Back
          </button>
          <button
            onClick={() => onRun(validation.files.map((f, i) => ({ role: f.role, mapping: mappings[i] || {} })))}
            disabled={busy || missing.length > 0}
            className="rounded-full bg-primary px-5 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 disabled:opacity-50"
          >
            {busy ? 'Starting…' : 'Run the analysis'}
          </button>
        </div>
      </div>
    );
  }

  /* ---------- step 3: progress ---------- */

  function RunningStep({ job }) {
    const progress = job?.progress || 0;
    return (
      <div className="mx-auto max-w-xl py-16">
        <div className="rounded-2xl border border-border bg-card p-6 text-center">
          <div className="flex items-baseline justify-center gap-1.5">
            <span className="text-5xl font-semibold leading-none tracking-tight tabular-nums text-foreground">
              {progress}
            </span>
            <span className="text-lg font-medium text-muted-foreground">%</span>
          </div>
          <div className="mt-5 h-1.5 w-full overflow-hidden rounded-full bg-card-raised">
            <div
              className="h-full rounded-full bg-primary transition-all duration-300"
              style={{ width: `${progress}%` }}
            />
          </div>
          <h1 className="mt-5 text-sm font-semibold text-foreground">Analysing your shipments</h1>
          <p className="mt-1 text-xs text-muted-foreground">{job?.message || 'Starting…'}</p>
        </div>
      </div>
    );
  }

  /* ---------- step 4: report ---------- */

  function ScenarioTable({ report }) {
    const scenarios = report.scenarios;
    const b = report.baseline;
    const rows = [
      ['Box sizes stocked', num(b.boxCount), s => num(s.boxCount)],
      ['New sizes required', '—', s => num(s.newBoxCount)],
      ['What changes', 'Nothing', s => s.compositionNote],
      ['Total fulfilment cost', money(b.totalCostCents), s => money(s.totalCostCents)],
      ['Transportation costs', money(b.totalTransportCents), s => money(s.totalTransportCents)],
      ['Cartons', money(b.totalBoxCents), s => money(s.totalBoxCents)],
      ['Saving', '—', s => money(s.savingsCents), true],
      ['Saving %', '—', s => pct(s.savingsPercent), true],
      ['Transportation cost saved', '—', s => money(s.transportSavingsCents)],
      ['Carton cost saved', '—', s => money(s.boxSavingsCents)],
      ['Average fill', pct(b.avgFillPercent), s => pct(s.avgFillPercent)],
      ['Billable weight (lb)', num(b.totalBillableWeightLb), s => num(s.totalBillableWeightLb)],
      ['Coverage', '100.00%', s => pct(s.coveragePercent)],
      ['Orders moved to a larger box', '—', s => num(s.shipmentsUpsized)],
    ];

    // No border of its own: this only ever renders inside a flush Disclosure.
    return (
      <div className="overflow-x-auto bg-card">
        <table className="min-w-full text-sm">
          <thead className="bg-muted text-2xs uppercase tracking-widest text-muted-foreground">
            <tr>
              <th className="px-3 py-2.5 text-left">Metric</th>
              <th className="px-3 py-2.5 text-right">Today</th>
              {scenarios.map(s => (
                <th
                  key={s.key}
                  className={`px-3 py-2.5 text-right ${
                    s.key === report.recommendation.scenarioKey ? 'bg-primary/10 text-primary-ink' : ''
                  }`}
                >
                  {s.label}
                </th>
              ))}
            </tr>
          </thead>
          <tbody className="divide-y divide-border">
            {rows.map(([label, baseVal, fn, emphasis]) => (
              <tr key={label} className={emphasis ? 'bg-muted/60 font-medium' : ''}>
                <td className="whitespace-nowrap px-3 py-2 text-foreground">{label}</td>
                <td className="px-3 py-2 text-right tabular-nums text-muted-foreground">{baseVal}</td>
                {scenarios.map(s => {
                  const value = fn(s);
                  const negative = emphasis && s.savingsCents < 0;
                  return (
                    <td
                      key={s.key}
                      className={`px-3 py-2 text-right ${
                        label === 'What changes' ? 'whitespace-normal' : 'tabular-nums whitespace-nowrap'
                      } ${s.key === report.recommendation.scenarioKey ? 'bg-primary/5' : ''} ${
                        emphasis ? (negative ? 'text-danger' : 'text-success') : 'text-foreground'
                      }`}
                    >
                      {value}
                    </td>
                  );
                })}
              </tr>
            ))}
          </tbody>
        </table>
      </div>
    );
  }

  /**
   * One scenario as a card on the board.
   *
   * The selected card inverts to solid foreground rather than picking up an accent
   * border, and only then reveals its secondary rows — so the board has exactly one
   * loud element and the rest stays quiet enough to scan.
   */
  function ScenarioCard({ scenario: s, baseline, selected, recommended, onSelect }) {
    return (
      <button
        onClick={onSelect}
        aria-pressed={selected}
        className={cn(
          'w-full rounded-2xl border p-4 text-left transition-all',
          selected
            ? 'border-transparent bg-foreground text-background shadow-lg'
            : 'border-border bg-card hover:border-foreground/25 hover:shadow-sm'
        )}
      >
        <div className="flex items-start justify-between gap-2">
          <span
            className={cn(
              'text-2xs font-bold uppercase tracking-widest tabular-nums',
              selected ? 'text-background/60' : 'text-muted-foreground'
            )}
          >
            {num(s.boxCount)} box sizes
          </span>
          {recommended && (
            <Chip invert={selected} tone="accent" icon={Icons.Sparkle}>
              Best saving
            </Chip>
          )}
        </div>

        <div className="mt-1.5 text-sm font-medium leading-snug">{s.label}</div>

        <div
          className={cn(
            'mt-3 flex items-baseline gap-1.5',
            selected ? 'text-background' : 'text-success'
          )}
        >
          <span className="text-3xl font-semibold leading-none tabular-nums">
            {money(s.savingsCents)}
          </span>
          <span
            className={cn(
              'text-xs font-medium',
              selected ? 'text-background/70' : 'text-muted-foreground'
            )}
          >
            saved
          </span>
        </div>

        {/* The bill drawn once, with the saving as the slice of it that comes back:
            two bars two percent apart would read as no change at all. */}
        <div className="mt-4">
          <SavingSplit
            invert={selected}
            paid={baseline.totalCostCents}
            saved={s.savingsCents}
          />
        </div>

        <div className="mt-4 flex flex-wrap gap-1.5">
          {s.newBoxCount > 0 && (
            <Chip invert={selected} icon={Icons.Sparkle}>
              {num(s.newBoxCount)} new to order
            </Chip>
          )}
          {s.boxCount - s.newBoxCount > 0 && (
            <Chip invert={selected} icon={Icons.Box}>
              {num(s.boxCount - s.newBoxCount)} you already stock
            </Chip>
          )}
          <Chip invert={selected} icon={Icons.Gauge}>
            {pct(s.avgFillPercent)} full
          </Chip>
          <Chip invert={selected} icon={Icons.Truck}>
            {money(s.transportSavingsCents)} less transportation
          </Chip>
        </div>

        <p
          className={cn(
            'mt-3 text-2xs leading-relaxed',
            selected ? 'text-background/70' : 'text-muted-foreground'
          )}
        >
          {s.compositionNote}
        </p>

        {selected ? (
          <dl className="mt-3 space-y-1.5 border-t border-background/15 pt-3 text-2xs">
            {[
              ['Transportation costs you would pay', money(s.totalTransportCents)],
              ['Cardboard you would pay', money(s.totalBoxCents)],
              ['Both, together', money(s.totalCostCents)],
              ['Weight the carrier stops billing', `${num(s.billableWeightSavedLb)} lb`],
              ['Orders that still fit', pct(s.coveragePercent)],
              ['Orders that move to a bigger box', num(s.shipmentsUpsized)],
            ].map(([k, v]) => (
              <div key={k} className="flex items-baseline justify-between gap-3">
                <dt className="text-background/60">{k}</dt>
                <dd className="tabular-nums text-background">{v}</dd>
              </div>
            ))}
          </dl>
        ) : (
          <div className="mt-3 flex items-center gap-1.5 border-t border-border pt-3 text-2xs font-medium text-primary-ink">
            See the boxes it needs
            <Icons.ArrowRight className="h-3 w-3" />
          </div>
        )}
      </button>
    );
  }

  /**
   * The options, one column each.
   *
   * Only slots that saved money get a column: a lineup that costs more than what
   * the warehouse runs today is not an option, and rendering it as an empty column
   * only asks the reader to work out that it does not matter. The grid tracks the
   * number of columns so one option does not stretch across a three-wide row.
   */
  function ScenarioBoard({ report, scenarios, activeKey, onSelect }) {
    const filled = SLOTS.map(slot => ({
      slot,
      scenario: scenarios.find(s => s.slot === slot.slot),
    })).filter(entry => entry.scenario);

    const cols =
      filled.length >= 3 ? 'sm:grid-cols-2 xl:grid-cols-3' : filled.length === 2 ? 'sm:grid-cols-2' : 'max-w-md';

    return (
      <div className={cn('grid gap-5', cols)}>
        {filled.map(({ slot, scenario }) => (
          <BoardColumn key={slot.slot} title={slot.title}>
            <ScenarioCard
              scenario={scenario}
              baseline={report.baseline}
              selected={scenario.key === activeKey}
              recommended={scenario.key === report.recommendation.scenarioKey}
              onSelect={() => onSelect(scenario.key)}
            />
            <p className="px-1 text-2xs leading-relaxed text-muted-foreground">{slot.blurb}</p>
          </BoardColumn>
        ))}
      </div>
    );
  }

  /** Box-by-box specification for whichever scenario is selected on the board. */
  function ScenarioSpec({ scenario: s }) {
    if (!s) return null;
    return (
      <div className="space-y-3">
        {/* Sizes drawn before sizes tabulated: the picture answers "how big is
            that?" and the table answers "what does it cost?". */}
        <Panel
          title="The lineup, drawn to scale"
          subtitle="Each box seen from above, all on the same scale. The figure in the middle is how tall it is. Green boxes are the new sizes; grey ones you already stock."
        >
          <BoxLadder boxes={s.boxes} />
        </Panel>

        <Table
          headers={[
            'Box',
            'Dimensions',
            'Est. carton cost',
            'Shipments',
            'Share',
            'Avg fill',
            'Avg transportation cost',
            'Saving contribution',
            'Replaces',
          ]}
          align={{ 2: 'r', 3: 'r', 4: 'r', 5: 'r', 6: 'r', 7: 'r' }}
          rows={s.boxes.map(b => [
            <span className="flex items-center gap-2">
              <span className="font-medium">{b.id}</span>
              {!b.isIncumbent && (
                <span className="rounded bg-primary/10 px-1.5 py-0.5 text-2xs font-bold uppercase tracking-wide text-primary-ink">
                  new
                </span>
              )}
            </span>,
            dims(b),
            money2(b.costCents),
            num(b.shipmentCount),
            pct(b.sharePercent),
            pct(b.avgFillPercent),
            money2(b.avgTransportCents),
            <span className={b.contributionCents >= 0 ? 'text-success' : 'text-danger'}>
              {money(b.contributionCents)}
            </span>,
            Object.entries(b.replaces)
              .sort((x, y) => y[1] - x[1])
              .slice(0, 3)
              .map(([id, n]) => `${id} (${num(n)})`)
              .join(', ') || '—',
          ])}
        />
      </div>
    );
  }

  /** What the report says when no assortment beat the current lineup. */
  function NoSavingsPanel({ report }) {
    return (
      <div className="rounded-2xl border border-warning/30 bg-warning/10 p-5">
        <div className="flex items-center gap-2">
          <Icons.Info className="h-4 w-4 text-warning" />
          <span className="text-2xs font-bold uppercase tracking-widest text-warning">
            No savings found
          </span>
        </div>
        <div className="mt-2 text-xl font-semibold tracking-tight text-foreground">
          Your current box lineup is already the cheaper option.
        </div>
        <p className="mt-1.5 max-w-3xl text-sm leading-relaxed text-muted-foreground">{report.flag}</p>
        <p className="mt-3 max-w-3xl text-xs leading-relaxed text-muted-foreground">
          The numbers below still hold: your fill rate, your carton spend and the transportation costs you
          pay are measured from your own file. If transportation costs were not in either upload,
          add them and run this
          again — a better-fitting box only turns into money once there is a rate to price it
          against.
        </p>
      </div>
    );
  }

  function ReportView({ job, onRestart }) {
    const report = job.report;
    const b = report.baseline;
    // Belt and braces: the engine only promotes assortments that save money, and
    // an option that costs more is not shown even if one ever arrives.
    const scenarios = report.scenarios.filter(s => s.savingsCents > 0);
    const recommended =
      scenarios.find(s => s.key === report.recommendation.scenarioKey) || scenarios[0] || null;

    // The board and the specification table below it read the same selection, so
    // clicking a card is what changes the box list — there is no second control.
    const [activeKey, setActiveKey] = useState(recommended?.key);
    const active = scenarios.find(s => s.key === activeKey) || recommended;

    // Transportation is where the money is, and its share of the bill is the reason
    // a better-fitting box pays for itself — so it is stated rather than left to be
    // worked out of two totals.
    const transportShare =
      b.totalCostCents > 0 ? (b.totalTransportCents / b.totalCostCents) * 100 : 0;
    const fillGain = recommended ? recommended.avgFillPercent - b.avgFillPercent : 0;

    return (
      <div>
        <PageHeader
          title="Optimization analysis"
          description={`${job.fileName} — ${num(b.shipmentCount)} shipments, ${num(
            b.orderCount
          )} orders, ${num(report.validation.skuCount)} SKUs, ${num(b.boxCount)} box sizes`}
          actions={
            <React.Fragment>
              <a
                href={`${API}/api/audit/${job.jobId}/xlsx`}
                className="rounded-full bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90"
              >
                Download workbook
              </a>
              <button
                onClick={onRestart}
                className="rounded-full border border-border px-4 py-2 text-sm font-medium text-foreground transition-colors hover:bg-card-raised"
              >
                New analysis
              </button>
            </React.Fragment>
          }
        />

        {report.verdict === 'no_savings' && <NoSavingsPanel report={report} />}

        {scenarios.length > 0 && (
          <Section
            title={`${scenarios.length === 1 ? 'One way' : `${num(scenarios.length)} ways`} to save`}
            subtitle={`These ${num(b.shipmentCount)} shipments cost you ${money(
              b.totalCostCents
            )}, in boxes the goods filled ${pct(
              b.avgFillPercent
            )} of on average. Each option below is a different set of box sizes you could stock instead — the dollar figure is what it would have saved on this very same history. Pick one to see the boxes it needs.`}
          >
            <ScenarioBoard
              report={report}
              scenarios={scenarios}
              activeKey={activeKey}
              onSelect={setActiveKey}
            />
          </Section>
        )}

        {active && (
          <Section
            title="What to order"
            subtitle={`${active.label} — ${num(active.newBoxCount || 0)} new size(s) to order, ${num(
              (active.boxCount || 0) - (active.newBoxCount || 0)
            )} you already stock.`}
            right={
              <Chip icon={Icons.Ruler}>
                {num(active.boxes?.length || 0)} sizes · {num(active.newBoxCount || 0)} new
              </Chip>
            }
          >
            <ScenarioSpec scenario={active} />
          </Section>
        )}

        <Section
          title="What your costs are made of"
          subtitle="Every dollar in this history splits two ways — what carriers charged to move the parcels, and what the cartons themselves cost."
        >
          <Panel
            title="Transportation costs vs. box costs"
            subtitle={
              active
                ? 'Your history on the left, the lineup selected above on the right — the same shipments, priced both ways.'
                : 'How the bill you have already paid divides between the two.'
            }
          >
            <div className="flex flex-wrap items-start justify-center gap-8 sm:gap-16">
              <CostDonut
                label="today"
                sublabel={`across ${num(b.shipmentCount)} shipments`}
                segments={costSegments(b)}
              />
              {active && (
                <>
                  <Icons.ArrowRight className="hidden h-6 w-6 shrink-0 self-center text-muted-foreground sm:block" />
                  <CostDonut
                    label={(SLOT_TITLE[active.slot] || active.label).toLowerCase()}
                    sublabel="the same shipments, repacked"
                    segments={costSegments(active)}
                  />
                </>
              )}
            </div>
          </Panel>

          {/* The before/after that matters most, as a pair of dials rather than two
              percentages in a sentence. */}
          {recommended && (
            <div className="mt-3">
              <Panel
                title="Fill rate"
                subtitle="Today, and under the best option above."
              >
                <div className="flex flex-wrap items-center justify-center gap-6 sm:gap-12">
                  <TickGauge
                    percent={b.avgFillPercent}
                    label="Today"
                    sublabel={`across ${num(b.shipmentCount)} shipments`}
                  />
                  <Icons.ArrowRight className="hidden h-6 w-6 text-muted-foreground sm:block" />
                  <TickGauge
                    percent={recommended.avgFillPercent}
                    label={SLOT_TITLE[recommended.slot] || recommended.label}
                    sublabel={`${fillGain >= 0 ? '+' : ''}${fillGain.toFixed(1)} points on the same orders`}
                  />
                </div>
              </Panel>
            </div>
          )}
        </Section>

        <Section
          title="The boxes you run today"
          subtitle="Measured straight from the files you uploaded — this is the yardstick every option above is compared against."
        >
          {/* Full-width rows rather than side-by-side: the meter list is naturally
              tall and the metric cards are naturally short, and a two-column
              layout stretches whichever loses to fill the other's height. */}
          <div className="mb-3 grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
            <MetricCard
              icon={Icons.Truck}
              label="What you spent"
              value={money(b.totalCostCents)}
              sub={`transportation costs and boxes, across ${num(b.shipmentCount)} shipments`}
            />
            <MetricCard
              icon={Icons.Gauge}
              label="Fill rate"
              value={pct(b.avgFillPercent)}
              sub="the share of the box your goods take up, on average"
            />
            <MetricCard
              icon={Icons.Chart}
              label="Transportation cost share"
              value={pct(transportShare)}
              sub={`${money(b.totalTransportCents)} of what you spent`}
            />
            <MetricCard
              icon={Icons.Weight}
              label="Weight billed"
              value={num(b.totalBillableWeightLb)}
              unit="lb"
              sub="what the carrier charged on, size included"
            />
          </div>

          {/* Share as bars, because concentration is the point: if two sizes carry
              most of the volume, those are the two worth re-cutting first. */}
          <div className="mb-3">
            <Panel
              title="Which boxes you reach for"
              subtitle="Share of shipments by box size, most-used first. The sizes at the top are the ones worth getting right."
            >
              <div className="grid gap-x-6 gap-y-3 sm:grid-cols-2 lg:grid-cols-3">
                {[...b.boxes]
                  .sort((x, y) => y.sharePercent - x.sharePercent)
                  .map((box, i) => (
                    <Meter
                      key={box.id}
                      label={box.id}
                      percent={box.sharePercent}
                      value={pct(box.sharePercent)}
                      tone={i === 0 ? 'default' : 'muted'}
                    />
                  ))}
              </div>
            </Panel>
          </div>

          <Table
            headers={[
              'Box',
              'Dimensions',
              'Carton cost',
              'Shipments',
              'Share',
              'Avg fill',
              'Avg transportation cost',
              'Total cost',
            ]}
            align={{ 2: 'r', 3: 'r', 4: 'r', 5: 'r', 6: 'r', 7: 'r' }}
            rows={b.boxes.map(box => [
              box.id,
              dims(box),
              money2(box.costCents),
              num(box.shipmentCount),
              pct(box.sharePercent),
              pct(box.avgFillPercent),
              money2(box.avgTransportCents),
              money(box.totalCostCents),
            ])}
          />
        </Section>

        <Section
          title="How these numbers were produced"
          subtitle="Fill, billable weight and coverage are exact. Dollars depend on the cost curves below."
        >
          <div className="space-y-3">
            {scenarios.length > 0 && (
              <Disclosure title="Every metric, every scenario, side by side" flush>
                <ScenarioTable report={report} />
              </Disclosure>
            )}
            <Disclosure title="Cost models fitted from your data" defaultOpen>
              <ul className="list-disc space-y-2 pl-5">
                {report.assumptions.map((a, i) => (
                  <li key={i}>{a}</li>
                ))}
              </ul>
            </Disclosure>
            <Disclosure title="What to keep in mind">
              <ul className="list-disc space-y-2 pl-5">
                {report.caveats.map((c, i) => (
                  <li key={i}>{c}</li>
                ))}
              </ul>
            </Disclosure>
            {report.validation.join && (
              <Disclosure title="How your two files were matched">
                <div className="not-prose">
                  <JoinPanel join={report.validation.join} />
                </div>
              </Disclosure>
            )}
            {report.validation.warnings.length > 0 && (
              <Disclosure title={`Data quality notes (${report.validation.warnings.length})`}>
                <ul className="list-disc space-y-2 pl-5">
                  {report.validation.warnings.map((w, i) => (
                    <li key={i}>{w}</li>
                  ))}
                </ul>
              </Disclosure>
            )}
          </div>
        </Section>

        <div className="h-16" />
      </div>
    );
  }

  /* ---------- shell ---------- */

  function PackagingAudit() {
    const [step, setStep] = useState('upload');
    const [preview, setPreview] = useState(null);
    const [schema, setSchema] = useState({ files: [], fields: [] });
    const [job, setJob] = useState(null);
    const [busy, setBusy] = useState(false);
    const [error, setError] = useState(null);
    /** The uploaded Files, and the roles we are asserting for them (if any). */
    const uploadRef = useRef({ files: [], roles: null });
    const pollRef = useRef(null);

    useEffect(() => {
      fetch(`${API}/api/audit/schema`)
        .then(r => r.json())
        .then(setSchema)
        .catch(() => {});
    }, []);

    const stopPolling = () => {
      if (pollRef.current) clearInterval(pollRef.current);
      pollRef.current = null;
    };
    useEffect(() => stopPolling, []);

    /**
     * Build the multipart body.
     *
     * Unlabelled `files` lets the server read the headers and decide; once the
     * customer has corrected a wrong guess we send `orders` and `boxes` instead,
     * which skips detection entirely. `roles` is parallel to `files`, and is only
     * honoured when it names exactly one of each — otherwise fall back to letting
     * the server decide rather than posting a malformed pair.
     */
    const formOf = (files, roles) => {
      const form = new FormData();
      const oi = roles ? roles.indexOf('orders') : -1;
      const bi = roles ? roles.indexOf('boxes') : -1;
      if (files.length === 2 && oi >= 0 && bi >= 0 && oi !== bi) {
        form.append('orders', files[oi]);
        form.append('boxes', files[bi]);
      } else {
        files.forEach(f => form.append('files', f));
      }
      return form;
    };

    const runPreview = useCallback(async (files, roles) => {
      setBusy(true);
      setError(null);
      uploadRef.current = { files, roles: roles || null };
      try {
        const res = await fetch(`${API}/api/audit/preview`, {
          method: 'POST',
          body: formOf(files, roles),
        });
        const data = await res.json();
        if (!res.ok) throw new Error(data.error || 'Could not read those files.');
        setPreview(data);
        setStep('mapping');
      } catch (err) {
        setError(err.message);
        setStep('upload');
      } finally {
        setBusy(false);
      }
    }, []);

    /**
     * Re-read the pair with the roles the other way round.
     *
     * The preview lists its files in role order, which is not necessarily the order
     * they were uploaded in, so the current assignment is recovered by file name
     * before being flipped. If that lookup is ambiguous we assert the reverse of
     * the upload order, which is still the opposite of whatever was shown.
     */
    const swapRoles = useCallback(() => {
      const { files, roles } = uploadRef.current;
      if (files.length !== 2) return;
      const detected = preview?.validation?.files || [];
      const current =
        roles ||
        files.map(f => {
          const match = detected.find(d => d.fileName === f.name);
          return match ? match.role : null;
        });
      const flipped = current.map(r => (r === 'orders' ? 'boxes' : 'orders'));
      const wellFormed =
        flipped.filter(r => r === 'orders').length === 1 &&
        flipped.filter(r => r === 'boxes').length === 1;
      runPreview(files, wellFormed ? flipped : ['boxes', 'orders']);
    }, [preview, runPreview]);

    const handleRun = useCallback(
      async perFile => {
        setBusy(true);
        setError(null);
        try {
          const { files, roles } = uploadRef.current;
          const form = formOf(files, roles);
          for (const { role, mapping } of perFile) {
            const clean = Object.fromEntries(
              Object.entries(mapping).filter(([, header]) => !!header)
            );
            const field =
              role === 'orders' ? 'ordersMapping' : role === 'boxes' ? 'boxesMapping' : 'mapping';
            form.append(field, JSON.stringify(clean));
          }

          const res = await fetch(`${API}/api/audit`, { method: 'POST', body: form });
          const data = await res.json();
          if (!res.ok) throw new Error(data.error || 'Could not start the audit.');

          setJob({ jobId: data.jobId, status: 'queued', progress: 0, message: 'Queued' });
          setStep('running');

          pollRef.current = setInterval(async () => {
            try {
              const poll = await fetch(`${API}/api/audit/${data.jobId}`);
              const state = await poll.json();
              if (!poll.ok) throw new Error(state.error || 'Lost track of the audit.');
              setJob(state);
              if (state.status === 'completed') {
                stopPolling();
                setStep('report');
              } else if (state.status === 'failed') {
                stopPolling();
                setError(state.error || 'The audit failed.');
                setStep('mapping');
              }
            } catch (err) {
              stopPolling();
              setError(err.message);
              setStep('mapping');
            }
          }, 700);
        } catch (err) {
          setError(err.message);
        } finally {
          setBusy(false);
        }
      },
      []
    );

    const restart = () => {
      stopPolling();
      uploadRef.current = { files: [], roles: null };
      setPreview(null);
      setJob(null);
      setError(null);
      setStep('upload');
    };

    return (
      <PageContainer>
        {step === 'upload' && (
          <UploadStep onPreview={files => runPreview(files)} busy={busy} error={error} />
        )}
        {step === 'mapping' && preview && (
          <div>
            {error && (
              <div className="mx-auto mb-4 max-w-4xl">
                <Notice>{error}</Notice>
              </div>
            )}
            <MappingStep
              preview={preview}
              schema={schema}
              onRun={handleRun}
              onBack={restart}
              onSwap={swapRoles}
              busy={busy}
            />
          </div>
        )}
        {step === 'running' && <RunningStep job={job} />}
        {step === 'report' && job?.report && <ReportView job={job} onRestart={restart} />}
      </PageContainer>
    );
  }

  window.PackagingAudit = PackagingAudit;
})();
