/* =================== SHIPMENTS (Royal Mail Click & Drop) =================== */
const { useState: useStateS, useEffect: useEffectS } = React;

const SHIP_STATUS = {
  created:          { label: "Created",          cls: "badge--ghost" },
  label_generated:  { label: "Label generated",  cls: "badge--blue"  },
  despatched:       { label: "Despatched",       cls: "badge--teal"  },
  manifested:       { label: "Manifested",       cls: "badge--green" },
};
const rmTrackUrl = (tn) => "https://www.royalmail.com/track-your-item#/tracking-results/" + encodeURIComponent(tn);
// Parse a fetch response as JSON, but degrade gracefully when the body is an HTML
// error page (e.g. a Cloudflare/nginx 502/504 gateway page) instead of the cryptic
// "Unexpected token '<'". Returns the parsed object or throws a clear message.
async function readJson(res) {
  const text = await res.text();
  try { return JSON.parse(text); }
  catch {
    const looksHtml = /^\s*<(!doctype|html)/i.test(text);
    if (looksHtml || !res.ok) {
      throw new Error(`Server returned ${res.status} ${res.statusText || ""} (non-JSON). The request may have timed out at the gateway — check the shipments list before retrying.`.trim());
    }
    throw new Error("Unexpected non-JSON response");
  }
}
function shipDate(s) {
  if (!s) return "—";
  const d = new Date(s);
  return `${d.getDate()} ${["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"][d.getMonth()]} ${String(d.getHours()).padStart(2,"0")}:${String(d.getMinutes()).padStart(2,"0")}`;
}
function openBase64Pdf(b64) {
  try {
    const bin = atob(b64); const arr = new Uint8Array(bin.length);
    for (let i = 0; i < bin.length; i++) arr[i] = bin.charCodeAt(i);
    window.open(URL.createObjectURL(new Blob([arr], { type: "application/pdf" })), "_blank");
  } catch (e) { alert("Couldn't open manifest PDF: " + e.message); }
}

// Today’s date as YYYY-MM-DD in local time.
function localDateStr(isoStr) {
  if (!isoStr) return "";
  const d = new Date(isoStr);
  return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,"0")}-${String(d.getDate()).padStart(2,"0")}`;
}
function todayStr() {
  const d = new Date();
  return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,"0")}-${String(d.getDate()).padStart(2,"0")}`;
}

function ShipmentsTable({ ships, busy, canVoid, onVoid, onLabel }) {
  if (!ships.length) return <div className="empty-state">No shipments.</div>;
  return (
    <div className="tbl-wrap">
      <table className="tbl ship-list">
        <thead><tr>
          <th>Ship date</th><th>Status</th><th>Order</th><th>Qty</th>
          <th>Batch</th><th>Tracking</th><th>Carrier</th>
          <th>Recipient</th><th>City</th><th>County</th><th>Country</th>
          <th style={{width:60}}></th>
        </tr></thead>
        <tbody>
          {ships.map(s => {
            const sm = SHIP_STATUS[s.status] || SHIP_STATUS.created;
            return (
              <tr key={s.orderIdentifier}>
                <td className="mono" style={{ fontSize:11, whiteSpace:"nowrap" }}>{shipDate(s.shippedOn || s.printedOn || s.createdOn)}</td>
                <td><span className={"badge " + sm.cls}>{sm.label}</span></td>
                <td className="mono" style={{ fontSize:12 }}>{s.orderReference || s.orderIdentifier}</td>
                <td className="mono" style={{ textAlign:"center" }}>{s.qty || "—"}</td>
                <td className="mono dim" style={{ fontSize:11 }}>{s.batchNumber || "—"}</td>
                <td>{s.trackingNumber
                  ? <a className="mono" href={rmTrackUrl(s.trackingNumber)} target="_blank" rel="noopener noreferrer"
                       style={{ color:"var(--mint-ink)", textDecoration:"underline", fontSize:11 }}>{s.trackingNumber}</a>
                  : <span className="dim">—</span>}</td>
                <td style={{ fontSize:11 }}>
                  {s.serviceCode
                    ? <span title={s.serviceDescription || s.serviceCode} className="mono">{s.serviceCode}</span>
                    : (s.requestedService ? <span title={"Requested: " + s.requestedService}>Royal Mail</span> : <span className="dim">—</span>)}
                </td>
                <td style={{ fontSize:12 }}>{s.recipientName || <span className="dim">—</span>}</td>
                <td style={{ fontSize:11 }}>{s.recipientCity || <span className="dim">—</span>}</td>
                <td style={{ fontSize:11 }}>{s.recipientCounty || <span className="dim">—</span>}</td>
                <td><span className="mono" style={{ fontSize:11 }}>{s.recipientCountry || "—"}</span></td>
                <td>
                  <div style={{ display:"flex", gap:4 }}>
                    {(s.trackingNumber || s.printedOn) &&
                      <button className="btn btn--ghost btn--sm" onClick={() => onLabel(s)} title="View label"><Icon name="print" /></button>}
                    {canVoid && (
                      <button className="btn btn--ghost btn--sm" disabled={busy || !s.voidable} onClick={() => onVoid(s)}
                              title={s.voidable ? "Void label" : "Manifested — can’t void"}
                              style={s.voidable ? {} : { opacity:.35 }}><Icon name="x" /></button>
                    )}
                  </div>
                </td>
              </tr>
            );
          })}
        </tbody>
      </table>
    </div>
  );
}

function Shipments({ go }) {
  const [tab, setTab] = useStateS("today"); // "today" | "history" | "manifests"
  const [data, setData] = useStateS(null);
  const [manifests, setManifests] = useStateS(null); // logged end-of-day manifests
  const [busy, setBusy] = useStateS(false);
  const [msg, setMsg] = useStateS("");
  const [histDays, setHistDays] = useStateS(30);

  function load() {
    setMsg("");
    if (tab === "manifests") {
      setManifests(null);
      fetch("/api/manifest-log").then(r => r.json()).then(j => setManifests(j.manifests || [])).catch(e => setManifests({ error: e.message }));
      return;
    }
    setData(null);
    // Open tab: look back a couple of weeks so any un-manifested stragglers show, not
    // just today. Closed tab: the chosen history window.
    const days = tab === "today" ? 14 : histDays;
    fetch(`/api/shipments?days=${Math.max(days, 1)}&labelled=1`).then(r => r.json()).then(setData).catch(e => setData({ error: e.message }));
  }
  useEffectS(load, [tab, histDays]);

  const today = todayStr();
  // Only shipments that have a label created (tracking or printed) belong in the list.
  const allShips = ((data && data.shipments) || []).filter(s => s.trackingNumber || s.printedOn);
  // Split by STATUS, not date: Open = labelled but not yet manifested (what end-of-day
  // will close); Closed = manifested. So once you run the manifest, today's orders move
  // straight to Closed.
  const isManifested = (s) => s.status === "manifested" || !!s.manifestedOn;
  const ships = tab === "today"
    ? allShips.filter(s => !isManifested(s))
    : allShips.filter(s => isManifested(s));

  const isEligible = (s) => s.status === "despatched" || s.status === "label_generated";
  // Royal Mail's manifest is account-wide (POST /manifests takes no order list),
  // so "end of day" closes ALL labelled orders in one manifest — no per-order pick.
  const eligible = (tab === "today" ? allShips : ships).filter(isEligible).length;

  async function endOfDay() {
    if (!confirm(`Close today’s manifest? Royal Mail manifests ALL ${eligible} labelled order(s) at once — this can’t be undone.`)) return;
    setBusy(true); setMsg("");
    try {
      const r = await fetch("/api/manifests", { method: "POST", headers: { "Content-Type": "application/json" }, body: "{}" });
      const j = await readJson(r);
      if (!r.ok) throw new Error(j.error || "Manifest failed");
      setMsg(`Manifest ${j.manifestNumber || ""} created.`);
      if (j.documentPdf) openBase64Pdf(j.documentPdf);
      load();
    } catch (e) { setMsg("Error: " + e.message); }
    finally { setBusy(false); }
  }

  async function voidShip(s) {
    if (!s.voidable) return;
    if (!confirm(`Void label for ${s.orderReference || s.orderIdentifier}? The label becomes invalid and the order is reverted in Shopify.`)) return;
    setBusy(true); setMsg("");
    try {
      const j = await DTF.voidShipment(s.orderIdentifier, s.orderReference || "");
      let note = `Voided ${s.orderReference || s.orderIdentifier}.`;
      if (j && j.reverted) note += " Reverted in Shopify.";
      else if (j && j.revertError) note += " (Shopify revert failed — un-dispatch manually.)";
      setMsg(note);
      load();
    } catch (e) { setMsg("Error: " + e.message); }
    finally { setBusy(false); }
  }

  const viewLabel = (s) => window.open("/api/shipments/" + s.orderIdentifier + "/label?type=postageLabel", "_blank");

  const todayCount = allShips.filter(s => localDateStr(s.createdOn) === today).length;

  return (
    <div className="board">
      <div className="stats">
        <div className="stat"><div className="stat__k">Today</div><div className="stat__v">{todayCount}</div><div className="stat__d">labels created</div></div>
        <div className="stat"><div className="stat__k">Ready to manifest</div><div className="stat__v">{eligible}</div><div className="stat__d">despatched / labelled</div></div>
        <div className="stat"><div className="stat__k">Manifested</div><div className="stat__v">{allShips.filter(s => s.status === "manifested").length}</div><div className="stat__d">closed</div></div>
      </div>

      <div className="toolbar">
        <div className="ship-tabs">
          <button type="button" className={"ship-tab" + (tab === "today" ? " is-on" : "")} onClick={() => setTab("today")}>
            Open {tab === "today" && ships.length > 0 && <span className="cnt">{ships.length}</span>}
          </button>
          <button type="button" className={"ship-tab" + (tab === "history" ? " is-on" : "")} onClick={() => setTab("history")}>
            Closed
          </button>
          <button type="button" className={"ship-tab" + (tab === "manifests" ? " is-on" : "")} onClick={() => setTab("manifests")}>
            Manifests
          </button>
        </div>
        {tab === "history" && (
          <div className="status-select">
            <Icon name="filter" />
            <select value={histDays} onChange={e => setHistDays(Number(e.target.value))}>
              <option value={7}>Last 7 days</option>
              <option value={30}>Last 30 days</option>
              <option value={90}>Last 90 days</option>
            </select>
          </div>
        )}
        <button className="btn btn--ghost btn--sm" onClick={load}><Icon name="refresh" /> Refresh</button>
        <div className="right" style={{ display:"flex", gap:8, alignItems:"center" }}>
          {msg && <span className="kick">{msg}</span>}
          {tab === "today" && (
            <button className="btn btn--primary btn--sm" disabled={busy || eligible === 0} onClick={endOfDay}>
              <Icon name="doc" /> End of day — Manifest ({eligible})
            </button>
          )}
        </div>
      </div>

      {tab === "manifests" ? (
        !manifests ? (
          <div className="empty"><span className="spin spin--dark" /><div className="t">Loading manifests…</div></div>
        ) : manifests.error ? (
          <div className="empty-state">Couldn’t load manifests — {manifests.error}</div>
        ) : !manifests.length ? (
          <div className="empty-state">No manifests recorded yet. They appear here after you run “End of day — Manifest”. Older ones are in Click &amp; Drop → Manifests.</div>
        ) : (
          <table className="ship-tbl">
            <thead><tr><th>Manifest</th><th>Created</th><th>Orders</th><th></th></tr></thead>
            <tbody>
              {manifests.map(m => (
                <tr key={m.number}>
                  <td className="mono">#{m.number}</td>
                  <td>{new Date(m.createdAt).toLocaleString("en-GB")}</td>
                  <td>{m.orderCount != null ? m.orderCount : "—"}</td>
                  <td className="right">{m.hasPdf
                    ? <a className="btn btn--ghost btn--sm" href={`/api/manifest-log/${encodeURIComponent(m.number)}/pdf`} target="_blank" rel="noopener"><Icon name="download" /> Download</a>
                    : <span className="kick">in Click &amp; Drop</span>}</td>
                </tr>
              ))}
            </tbody>
          </table>
        )
      ) : !data ? (
        <div className="empty"><span className="spin spin--dark" /><div className="t">Loading shipments…</div></div>
      ) : data.error ? (
        <div className="empty-state">Couldn’t load shipments — {data.error}</div>
      ) : (
        <ShipmentsTable ships={ships} busy={busy} canVoid={tab === "today"} onVoid={voidShip} onLabel={viewLabel} />
      )}

      {tab === "history" && (
        <div className="kick" style={{ padding:"8px 16px", color:"var(--txt-3)", fontSize:11 }}>
          Manifested shipments cannot be voided — Royal Mail limitation.
        </div>
      )}
    </div>
  );
}

window.Shipments = Shipments;

/* ---- Create label modal (single or bulk) with pre-send validation ---- */
const PKG_FORMATS = ["letter", "largeLetter", "smallParcel", "mediumParcel", "largeParcel", "parcel", "documents"];
// Service codes never offered in the create-label picker (returns / intl tracked).
const HIDDEN_SERVICE_CODES = ["TSS", "MPR"];
// Normalise a requested delivery title into a stable mapping key (matches server).
const normServiceKey = (title) => String(title || "").toLowerCase().replace(/[^a-z0-9]+/g, " ").trim();
const BOX_FORMATS = ["smallParcel", "mediumParcel", "largeParcel", "parcel"];
const ENVELOPE_FORMATS = ["letter", "largeLetter"];
const FORMAT_LABELS = {
  letter: "Letter", largeLetter: "Large Letter",
  smallParcel: "Small Parcel", mediumParcel: "Medium Parcel",
  largeParcel: "Large Parcel", parcel: "Parcel", documents: "Documents",
};
const svcIsLetter = (s) => !!s && (s.format === "letter" || s.format === "largeLetter");
const svcTier = (name) => { const m = String(name || "").match(/\b(24|48|9am|1pm|SD)\b/i); return m ? m[1].toUpperCase() : ""; };
// Infer the intended speed tier from a checkout delivery title, e.g.
// "Royal Mail Tracked 48" → "48", "Special Delivery by 1pm" → "1PM".
const tierFromTitle = (title) => {
  const t = String(title || "").toLowerCase();
  if (/\b48\b/.test(t)) return "48";
  if (/\b24\b/.test(t)) return "24";
  if (/1\s*pm|by\s*1pm/.test(t)) return "1PM";
  if (/9\s*am|by\s*9am/.test(t)) return "9AM";
  if (/special/.test(t)) return "1PM";
  if (/free|no charge|complimentary/.test(t)) return "24"; // free delivery → Royal Mail 24
  return "";
};
// Pick the enabled service matching the requested title's tier (right family for
// the parcel format). Returns a service id or null when nothing matches.
function serviceFromRequest(services, title, wantLetter) {
  const tier = tierFromTitle(title);
  if (!tier) return null;
  const pool = services.filter(s => s.enabled !== false);
  const inFamily = pool.filter(s => svcIsLetter(s) === wantLetter);
  const m = inFamily.find(s => svcTier(s.name) === tier) || pool.find(s => svcTier(s.name) === tier);
  return m ? m.id : null;
}
// Relative speed of a tier (higher = faster) for the "upgrade the service" nudge.
const TIER_SPEED = { "48": 1, "24": 2, "1PM": 3, "SD": 3, "9AM": 4 };
// The next-faster same-family service than the one selected (e.g. 48 → 24), or null.
function fasterService(services, currentId) {
  const cur = services.find(s => s.id === currentId);
  if (!cur) return null;
  const wantLetter = svcIsLetter(cur);
  const curRank = TIER_SPEED[svcTier(cur.name)] || 0;
  // Only ever recommend up to Tracked 24 (48 → 24). Tracked 24 is fast enough for
  // an overdue order — don't push Special Delivery (that's an expensive overkill).
  const CAP = TIER_SPEED["24"];
  const faster = services
    .filter(s => s.enabled !== false && svcIsLetter(s) === wantLetter
      && (TIER_SPEED[svcTier(s.name)] || 0) > curRank
      && (TIER_SPEED[svcTier(s.name)] || 0) <= CAP)
    .sort((a, b) => (TIER_SPEED[svcTier(a.name)] || 0) - (TIER_SPEED[svcTier(b.name)] || 0));
  return faster.length ? faster[0].id : null;   // the immediate next tier up (max 24)
}
// Choose the service that fits a parcel format: keep the customer/global preferred
// one if its format already matches, otherwise swap to the same-tier service in
// the right family — e.g. a Letter format picks "Tracked 24 (Letter)" over parcel.
function pickServiceForFormat(services, format, preferredId) {
  const wantLetter = format === "letter" || format === "largeLetter";
  const pool = services.filter(s => s.enabled !== false);
  const list = pool.length ? pool : services;
  const pref = services.find(s => s.id === preferredId);
  if (pref && svcIsLetter(pref) === wantLetter) return pref.id;
  const tier = pref ? svcTier(pref.name) : "";
  const matches = list.filter(s => svcIsLetter(s) === wantLetter);
  const sameTier = tier ? matches.find(s => svcTier(s.name) === tier) : null;
  return ((sameTier || matches[0] || pref || list[0]) || {}).id || "";
}
// Switch parcel type (box/envelope) keeping the same speed tier where possible.
function pickServiceForType(services, type, currentServiceId) {
  const wantLetter = type === "envelope";
  const pool = services.filter(s => s.enabled !== false);
  const list = pool.length ? pool : services;
  const pref = list.find(s => s.id === currentServiceId);
  if (pref && svcIsLetter(pref) === wantLetter) return pref.id;
  const tier = pref ? svcTier(pref.name) : "";
  const matches = list.filter(s => svcIsLetter(s) === wantLetter);
  const sameTier = tier ? matches.find(s => svcTier(s.name) === tier) : null;
  return ((sameTier || matches[0] || pref || list[0]) || {}).id || "";
}

/* Two-icon parcel type selector with service chips and size dropdown */
function ParcelTypePicker({ services, serviceId, format, onChange, requestedServiceId }) {
  const curSvc = services.find(s => s.id === serviceId);
  const curType = curSvc && svcIsLetter(curSvc) ? "envelope" : "box";
  const envelSvcs = services.filter(s => svcIsLetter(s));
  const boxSvcs   = services.filter(s => !svcIsLetter(s));
  const shownSvcs = curType === "envelope" ? envelSvcs : boxSvcs;
  // Compare by speed TIER, not service id — switching Box↔Envelope keeps the same
  // tier (TPS48 → TRS48) and must NOT read as "changed". Only a different tier
  // (e.g. 48 → 24) counts as a deviation from what the customer requested.
  const reqSvc = services.find(s => s.id === requestedServiceId);
  const reqTier = reqSvc ? svcTier(reqSvc.name) : "";
  const curTier = curSvc ? svcTier(curSvc.name) : "";
  const deviated = reqTier && curTier && reqTier !== curTier;

  function switchType(type) {
    const newSvcId = pickServiceForType(services, type, serviceId);
    const wantLetter = type === "envelope";
    const fmtOk = wantLetter ? ENVELOPE_FORMATS.includes(format) : BOX_FORMATS.includes(format);
    const newFormat = fmtOk ? format : (wantLetter ? "largeLetter" : "smallParcel");
    onChange({ serviceId: newSvcId, format: newFormat });
  }

  return (
    <div className="ptype-picker">
      <div className="ptype-picker__icons">
        <button type="button" className={"ptype-btn" + (curType === "box" ? " is-on" : "")}
                onClick={() => switchType("box")} title="Box / large parcel">
          <Icon name="box" /><span>Box</span>
        </button>
        <button type="button" className={"ptype-btn" + (curType === "envelope" ? " is-on" : "")}
                onClick={() => switchType("envelope")} title="Envelope / letterbox">
          <Icon name="mail" /><span>Envelope</span>
        </button>
      </div>
      <div className="ptype-picker__svcs">
        {shownSvcs.map(s => {
          const tier = svcTier(s.name);
          const label = tier || s.code || s.name;   // fall back to the short code, not the long name
          const isSel = serviceId === s.id;
          // Mark the chip whose tier matches the requested service (within this family).
          const isReq = reqTier && tier === reqTier;
          const cls = "svc-chip"
            + (isSel ? " is-on" : "")
            + (isReq ? " is-requested" : "")
            + (isSel && deviated ? " is-deviated" : "");   // picked tier ≠ requested tier → highlight hard
          return (
            <button key={s.id} type="button" className={cls}
                    onClick={() => onChange({ serviceId: s.id })}
                    title={s.name + " (" + s.code + ")" + (isReq ? " — requested by customer" : "")}>
              {isReq && <span className="svc-chip__req">requested</span>}
              <span className="svc-chip__tier">{label}</span>
              <span className="svc-chip__code">{s.code}</span>
            </button>
          );
        })}
        {!shownSvcs.length && <span className="dim" style={{ fontSize: 11 }}>No services configured</span>}
      </div>
    </div>
  );
}
function isIntl(o) { const cc = (o.ship && o.ship.countryCodeV2) || "GB"; return cc.toUpperCase() !== "GB"; }
// Northern Ireland (BT postcode — still countryCode GB) needs the SAME customs data
// as a true international parcel or Royal Mail Click & Drop can't generate the label
// (the order is created bare, then the label PDF 502s). Treat NI like intl for
// customs (HS code), but keep DOMESTIC services (RM24/48, not international rates).
function isNI(o) { const sh = o.ship || {}; const cc = (sh.countryCodeV2 || "GB").toUpperCase(); return cc === "GB" && /^bt\d/i.test(String(sh.zip || "").trim()); }
function needsCustoms(o) { return isIntl(o) || isNI(o); }
// Default customs classification for our goods — everything we ship is printed
// transfers / gang sheets. HS 4911910000 = "printed pictures, designs & photographs".
// Pre-fills the HS field on any customs (intl / NI) order so it's not left blank.
const DEFAULT_HS = { hs: "4911910000", desc: "Printed transfers" };
// Physical (billable, non-fee, non-removed) line items of an order.
function physicalItems(o) {
  return ((o && o.items) || []).filter(it => !it.removed && !(DTF.isFeeLine && DTF.isFeeLine(it)));
}
// Short human summary of what's in the box, e.g. "2× TriDri t-shirt · 1× Custom transfer".
function orderLineSummary(o) {
  const items = physicalItems(o);
  if (!items.length) return "";
  return items.map(it => `${it.qty || 1}× ${it.name}${it.variant ? " (" + it.variant + ")" : ""}`).join(" · ");
}
// A stable per-product key for remembering HS codes (legacy id, else slugified name).
function hsKeyForOrder(o) {
  const it = physicalItems(o)[0];
  if (!it) return "";
  if (it.productLegacyId) return "p:" + it.productLegacyId;
  return "n:" + String(it.name || "").toLowerCase().replace(/\s+/g, "-").slice(0, 60);
}
function addrIssues(o) {
  const sh = o.ship || {}; const cc = (sh.countryCodeV2 || "").toUpperCase(); const e = [];
  if (!sh.address1) e.push("address line 1"); if (!sh.city) e.push("city");
  if (!cc) e.push("country"); if (cc === "GB" && !sh.zip) e.push("postcode");
  return e;
}
function rowToShipment(o, row, svc) {
  const sh = o.ship || {}; const c = DTF.getCustomer(o.customerId);
  const cc = (sh.countryCodeV2 || "GB").toUpperCase();
  const intl = cc !== "GB";
  const customs = intl || isNI(o); // NI (BT postcode, still GB) also needs customs on each line
  // Physical (non-fee, non-removed) lines — these become the package contents so
  // Royal Mail OBA shows what's in the box (previously blank for domestic orders).
  const phys = ((o.items) || []).filter(it => !it.removed && !(DTF.isFeeLine && DTF.isFeeLine(it)));
  const totalQty = phys.reduce((a, it) => a + (it.qty || 0), 0) || 1;
  const wEach = Math.max(1, Math.round((Number(row.weight) || 0) / totalQty));
  // Royal Mail requires SKU unique per product in a package. Suffix any repeats
  // (two lines of the same variant) so a shared SKU doesn't get the label rejected.
  const usedSku = new Set();
  const uniqSku = (sku) => {
    if (!sku) return undefined;                 // no SKU → omit; no uniqueness constraint
    let u = sku, n = 2;
    while (usedSku.has(u)) u = sku + "-" + (n++);
    usedSku.add(u);
    return u;
  };
  const contents = (phys.length ? phys : [{ name: "DTF transfer", qty: totalQty, price: o.total || 0, sku: "" }]).map(it => ({
    name: String(it.name || "DTF transfer").slice(0, 50),
    SKU: uniqSku(it.sku),
    quantity: it.qty || 1,
    unitValue: Number(it.price) || 0,
    unitWeightInGrams: wEach,
    ...(customs ? {
      customsCode: row.hs,
      customsDescription: (row.hsDesc || it.name || "DTF transfer").slice(0, 50),
      originCountryCode: "GB",
      customsDeclarationCategory: "commercialSaleOfGoods",
    } : {}),
  }));
  return {
    reference: String(o.id).replace(/^#/, ""),
    recipient: {
      fullName: sh.name || (c && c.name) || "", addressLine1: sh.address1 || "", addressLine2: sh.address2 || "",
      city: sh.city || "", county: sh.province || "", postcode: sh.zip || "", countryCode: cc,
      phone: sh.phone || "", email: (c && c.email) || "",
    },
    // Royal Mail only accepts each service's OWN package format (e.g. TPN24 wants
    // "parcel", not "smallParcel"), so the selected service's format wins.
    weightGrams: Number(row.weight) || 0, packageFormat: (svc && svc.format) || row.format || "parcel", serviceCode: svc ? svc.code : "",
    subtotal: o.total || 0, shippingCost: 0, total: o.total || 0, currencyCode: "GBP",
    contents,
    // Windsor Framework: whether the recipient is a business (B2B). Royal Mail
    // requires this on GB→NI / international customs orders or it won't apply postage.
    isRecipientABusiness: row.isBusiness === true,
  };
}
function shipmentIssues(s) {
  const e = []; const r = s.recipient; const cc = (r.countryCode || "").toUpperCase();
  if (!r.addressLine1) e.push("address"); if (!r.city) e.push("city"); if (!cc) e.push("country");
  if (cc === "GB" && !r.postcode) e.push("postcode");
  if (!(s.weightGrams > 0)) e.push("weight"); if (!s.packageFormat) e.push("parcel size");
  if (!s.serviceCode) e.push("service");
  // Customs (HS) needed for true international AND GB→Northern Ireland (BT postcode) —
  // Click & Drop can't generate an NI label without it (was silently failing before).
  const ni = cc === "GB" && /^bt\d/i.test(String(r.postcode || "").trim());
  if ((cc && cc !== "GB" || ni) && !(s.contents && s.contents[0] && s.contents[0].customsCode && s.contents[0].customsDescription)) e.push("HS code");
  return e;
}

// Dispatch (Shopify fulfilment) after buying labels can transiently fail when a
// batch is created at once and Royal Mail / Shopify briefly rate-limit us. Run
// the fulfilments one at a time with a short gap, retrying each a few times with
// exponential backoff, so a momentary throttle never leaves an order
// un-dispatched. Returns a result per item, aligned to the input order.
async function runFulfillments(items, fn, { gapMs = 300, tries = 4 } = {}) {
  const out = [];
  for (let i = 0; i < items.length; i++) {
    if (i > 0) await new Promise((r) => setTimeout(r, gapMs));
    let ok = false, error = null;
    for (let a = 0; a < tries; a++) {
      try { await fn(items[i]); ok = true; break; }
      catch (e) {
        error = e;
        const msg = String((e && e.message) || e).toLowerCase();
        const retryable = /rate|throttl|429|503|timeout|network|failed to fetch/.test(msg);
        if (a === tries - 1 || !retryable) break;
        await new Promise((r) => setTimeout(r, Math.min(8000, 700 * Math.pow(2, a)))); // 0.7→1.4→2.8s
      }
    }
    out.push({ ok, error });
  }
  return out;
}

function ShipModal({ orders, onClose, onDone, groupChildren }) {
  const [services, setServices] = useStateS([]);
  // Rows pre-fill from the order (weight/parcel derived from line items, service
  // from the customer's saved default) once settings load — see the effect below.
  const [rows, setRows] = useStateS(() => orders.map((o) => {
    const est = (DTF.shipEstimate && DTF.shipEstimate(o)) || { weightGrams: 0, format: "smallParcel" };
    return { weight: est.weightGrams ? String(est.weightGrams) : "", format: est.format || "smallParcel", serviceId: "", hs: "", hsDesc: "", hsValue: "", saveDefault: false, hadDefault: false, requestedService: o.shippingTitle || "" };
  }));
  const [dispatch, setDispatch] = useStateS(true); // notify Shopify + customer on create
  // Direct printing: "browser" = open the PDF (old behaviour), else the agentId of a
  // connected station to print to silently. Defaults to the connected station so it
  // "just prints" — the operator can switch to browser per the picker.
  const [agents, setAgents] = useStateS(window.__dtfLabelAgents || []);
  const [printTo, setPrintTo] = useStateS(() => (window.directPrintStation && window.directPrintStation()) || "browser");
  useEffectS(() => {
    const refresh = window.dtfRefreshLabelAgents ? window.dtfRefreshLabelAgents() : Promise.resolve(window.__dtfLabelAgents || []);
    refresh.then((list) => {
      setAgents(list || []);
      let saved = ""; try { saved = localStorage.getItem("dtf_labelStation") || ""; } catch {}
      if (!saved) setPrintTo((window.directPrintStation && window.directPrintStation()) || "browser"); // auto-pick the station once agents load
    }).catch(() => {});
  }, []);
  const [busy, setBusy] = useStateS(false);
  const [err, setErr] = useStateS("");
  const childCount = Object.values(groupChildren || {}).reduce((s, a) => s + a.length, 0);
  useEffectS(() => {
    let alive = true;
    Promise.all([
      fetch("/api/settings/royalmail").then(r => r.json()).catch(() => ({})),
      (DTF.getCustomerShippingMany ? DTF.getCustomerShippingMany(orders.map(o => o.customerId)) : Promise.resolve({})).catch(() => ({})),
      fetch("/api/settings/hs-codes").then(r => r.json()).then(j => j.hsCodes || {}).catch(() => ({})),
      fetch("/api/settings/service-map").then(r => r.json()).then(j => j.serviceMap || {}).catch(() => ({})),
    ]).then(([d, custDefs, hsCodes, serviceMap]) => {
      if (!alive) return;
      // Hide services we never dispatch from here: Royal Mail Tracked Returns (TSS)
      // and International Tracked (MPR).
      const en = (d.services || []).filter(s => s.enabled && !HIDDEN_SERVICE_CODES.includes((s.code || "").toUpperCase()));
      setServices(en);
      const globalDef = d.defaultServiceId && en.find(s => s.id === d.defaultServiceId) ? d.defaultServiceId : (en[0] && en[0].id) || "";
      setRows(rs => rs.map((r, i) => {
        const cd = (custDefs && custDefs[String(orders[i].customerId)]) || null;
        // Preselect: the REQUESTED title's own tier is authoritative — "Tracked 48"
        // ALWAYS pre-picks 48. A learned mapping only fills in for titles that carry
        // no derivable tier (e.g. "Standard Delivery"). This stops a stale/wrong
        // mapping (a past 48→24 bug) from overriding an explicit tier.
        const reqKey = normServiceKey(orders[i].shippingTitle);
        let format = (cd && cd.format) || r.format || "smallParcel";
        const derivedId = serviceFromRequest(en, orders[i].shippingTitle, ENVELOPE_FORMATS.includes(format));
        const mappedId = reqKey && serviceMap[reqKey] && en.find(s => s.id === serviceMap[reqKey]) ? serviceMap[reqKey] : null;
        const chosenId = derivedId || mappedId;
        let serviceId;
        if (chosenId) {
          // Honour the learned/requested choice exactly; align the parcel format to it.
          const svc = en.find(s => s.id === chosenId);
          format = svcIsLetter(svc) ? (ENVELOPE_FORMATS.includes(format) ? format : "largeLetter")
                                     : (BOX_FORMATS.includes(format) ? format : "smallParcel");
          serviceId = chosenId;
        } else {
          const preferred = (cd && cd.serviceId) || globalDef;
          serviceId = pickServiceForFormat(en, format, preferred);
        }
        // Pre-fill the HS code/desc/value: a remembered per-product code wins, else the
        // default printed-transfers code for any customs order (intl / NI) so it's never
        // left blank (a blank HS is why GB→NI labels silently failed to generate).
        const saved = hsCodes[hsKeyForOrder(orders[i])] || null;
        const cust = needsCustoms(orders[i]);
        const hsPatch = (saved || cust) ? {
          hs: r.hs || (saved && saved.hs) || (cust ? DEFAULT_HS.hs : ""),
          hsDesc: r.hsDesc || (saved && saved.desc) || (cust ? DEFAULT_HS.desc : ""),
          hsValue: r.hsValue || (saved && saved.value != null && saved.value !== "" ? String(saved.value) : ""),
        } : {};
        return { ...r, format, serviceId, hadDefault: !!(cd && cd.serviceId), mappedFromRequest: !derivedId && !!mappedId, requestedServiceId: chosenId || null, ...hsPatch };
      }));
    });
    return () => { alive = false; };
  }, []);
  const set = (i, patch) => setRows(rs => rs.map((r, j) => j === i ? { ...r, ...patch } : r));
  const setParcel = (i, patch) => setRows(rs => rs.map((r, j) => {
    if (j !== i) return r;
    const next = { ...r, ...patch };
    // If only format changed (no explicit serviceId), re-align service family.
    if (patch.format && !patch.serviceId) next.serviceId = pickServiceForFormat(services, patch.format, r.serviceId);
    return next;
  }));
  const applyAll = (patch) => setRows(rs => rs.map(r => {
    const next = { ...r, ...patch };
    if (patch.format && !patch.serviceId) next.serviceId = pickServiceForFormat(services, patch.format, r.serviceId);
    return next;
  }));
  const applyAllType = (type) => setRows(rs => rs.map(r => {
    const newSvcId = pickServiceForType(services, type, r.serviceId);
    const wantLetter = type === "envelope";
    const fmtOk = wantLetter ? ENVELOPE_FORMATS.includes(r.format) : BOX_FORMATS.includes(r.format);
    const newFormat = fmtOk ? r.format : (wantLetter ? "largeLetter" : "smallParcel");
    return { ...r, serviceId: newSvcId, format: newFormat };
  }));
  const svcById = (id) => services.find(s => s.id === id);

  async function create() {
    setErr(""); setBusy(true);
    try {
      const shipments = orders.map((o, i) => rowToShipment(o, rows[i], svcById(rows[i].serviceId)));
      const bad = shipments.map((s) => ({ ref: s.reference, e: shipmentIssues(s) })).filter(x => x.e.length);
      if (bad.length) { setErr("Missing — " + bad.map(x => `${x.ref}: ${x.e.join(", ")}`).join(" · ")); setBusy(false); return; }
      const r = await fetch("/api/shipments/create", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ shipments, generateLabel: true }) });
      const j = await readJson(r);
      if (!r.ok) throw new Error(j.error || "Create failed");
      // An order can be CREATED in Click & Drop but come back without a label — Royal
      // Mail won't apply postage via the API for a GB→Northern Ireland (customs) order
      // until its declaration is completed in Click & Drop. Only open labels that exist;
      // surface the rest with clear guidance instead of a dead/CF label tab.
      const created = (j.created || []).filter(c => c.orderIdentifier);
      const labelled = created.filter(c => c.label);
      const unlabelled = created.filter(c => !c.label);
      const ids = labelled.map(c => c.orderIdentifier);
      if (ids.length) {
        if (printTo && printTo !== "browser") {
          // Silent print via the station agent; fall back to opening the PDF on any error.
          try {
            const pr = await fetch("/api/labels/print", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ ids: ids.join(";"), station: printTo, orderRef: orders.map(o => o.id).join(", ") }) });
            if (!pr.ok) throw new Error("queue failed");
          } catch { window.open("/api/shipments/" + ids.join(";") + "/label?type=postageLabel", "_blank"); }
        } else {
          window.open("/api/shipments/" + ids.join(";") + "/label?type=postageLabel", "_blank");
        }
      }
      if (unlabelled.length) {
        const list = unlabelled.map(c => "#" + c.reference + (c.labelError ? " — " + c.labelError : "")).join(", ");
        setErr("Royal Mail created the order but applied no postage / label for " + list + ". Common causes: a Northern Ireland (BT postcode) customs order, OR an OBA/account problem (e.g. the account is disabled/suspended). Finish in Click & Drop — if it's NI, service Tracked 24, HS 4911910000; if it won't apply postage there either, check the Royal Mail account status." + (labelled.length ? " Other labels opened in a new tab." : ""));
        if (!labelled.length) { setBusy(false); return; } // nothing labelled → don't dispatch/close
      }
      onDone && onDone();
      // Combined dispatch: each parent's child orders ride in the parent's parcel,
      // so mark them fulfilled in Shopify with the parent's tracking number.
      let childMsg = "";
      const childTasks = [];
      for (const o of orders) {
        const kids = (groupChildren && groupChildren[o.id]) || [];
        if (!kids.length) continue;
        const ref = String(o.id).replace(/^#/, "");
        const made = (j.created || []).find(c => String(c.reference) === ref);
        const tn = made && made.trackingNumber;
        for (const k of kids) childTasks.push({ id: k.id, tracking: tn ? { number: tn, company: "Royal Mail" } : null });
      }
      if (childTasks.length) {
        const withTn = childTasks.filter(t => t.tracking);
        const noTn = childTasks.filter(t => !t.tracking).map(t => t.id);
        const res = await runFulfillments(withTn, t => DTF.fulfillOrderAll(t.id, { tracking: t.tracking, notify: false }));
        const failed = withTn.filter((_, i) => !res[i].ok).map(t => t.id);
        const okN = withTn.length - failed.length;
        if (okN) childMsg += `${okN} child order${okN===1?"":"s"} dispatched in Shopify with the parent's tracking. `;
        if (noTn.length) childMsg += `${noTn.length} child order${noTn.length===1?"":"s"} (${noTn.join(", ")}) had no tracking from Royal Mail yet — fulfil them once it's assigned. `;
        if (failed.length) childMsg += `Failed to fulfil: ${failed.join(", ")}. `;
      }
      // Once a label is bought the order is dispatched: fulfil the PARENT in
      // Shopify with its own Royal Mail tracking and notify the customer.
      let parentMsg = "";
      if (dispatch) {
        const madeById = {};
        (j.created || []).forEach(c => { madeById[String(c.reference)] = c; });
        const parents = orders.map((o) => {
          const made = madeById[String(o.id).replace(/^#/, "")];
          return made && made.orderIdentifier != null
            ? { id: o.id, tracking: made.trackingNumber ? { number: made.trackingNumber, company: "Royal Mail" } : null }
            : null;
        }).filter(Boolean);
        const pres = await runFulfillments(parents, p => DTF.fulfillOrderAll(p.id, { tracking: p.tracking, notify: true }));
        const pfailed = parents.filter((_, i) => !pres[i].ok).map(p => p.id);
        const pok = parents.length - pfailed.length;
        if (pok) parentMsg += `${pok} order${pok===1?"":"s"} dispatched in Shopify (customer notified). `;
        if (pfailed.length) parentMsg += `Couldn't dispatch in Shopify: ${pfailed.join(", ")} — mark dispatched manually. `;
      }
      // Persist any "save as this customer's default" ticks.
      const defTasks = orders
        .map((o, i) => (rows[i].saveDefault && o.customerId && rows[i].serviceId) ? { customerId: o.customerId, serviceId: rows[i].serviceId, format: rows[i].format } : null)
        .filter(Boolean);
      if (defTasks.length && DTF.saveCustomerShipping) {
        await Promise.allSettled(defTasks.map(t => DTF.saveCustomerShipping(t.customerId, { serviceId: t.serviceId, format: t.format })));
      }
      // Learn the requested-service → chosen-service mapping ONLY for titles with no
      // derivable tier (e.g. "Standard Delivery"). Titles that already state a tier
      // ("Tracked 48") are handled by tier-derivation — learning them is what let a
      // wrong 48→24 mapping stick and override every RM48 order.
      const mapTasks = [];
      const seenKeys = new Set();
      orders.forEach((o, i) => {
        const key = normServiceKey(o.shippingTitle);
        if (key && !tierFromTitle(o.shippingTitle) && rows[i].serviceId && !seenKeys.has(key)) { seenKeys.add(key); mapTasks.push({ key, serviceId: rows[i].serviceId }); }
      });
      if (mapTasks.length) {
        await Promise.allSettled(mapTasks.map(t => fetch("/api/settings/service-map", {
          method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(t),
        })));
      }
      // Remember any HS code entered for a customs order (intl / NI), keyed by product,
      // so the next order of the same product pre-fills.
      const hsTasks = orders
        .map((o, i) => (needsCustoms(o) && rows[i].hs && hsKeyForOrder(o))
          ? { key: hsKeyForOrder(o), hs: rows[i].hs, desc: rows[i].hsDesc, value: rows[i].hsValue }
          : null)
        .filter(Boolean);
      if (hsTasks.length) {
        await Promise.allSettled(hsTasks.map(t => fetch("/api/settings/hs-codes/" + encodeURIComponent(t.key), {
          method: "PUT", headers: { "Content-Type": "application/json" },
          body: JSON.stringify({ hs: t.hs, desc: t.desc, value: t.value }),
        })));
      }
      const failMsg = (j.failed && j.failed.length)
        ? `Royal Mail rejected ${j.failed.length} of ${(j.created || []).length + j.failed.length}: `
          + j.failed.map(f => `${f.reference || "order"} — ${f.reason || "rejected"}`).join(" · ") + " "
        : "";
      const notes = (childMsg + parentMsg);
      if (failMsg || (notes && (notes.includes("Couldn't") || notes.includes("Failed") || notes.includes("no tracking")))) setErr((failMsg + notes).trim());
      else onClose();
    } catch (e) { setErr(e.message); }
    finally { setBusy(false); }
  }

  return (
    <div className="modal-overlay" onClick={onClose}>
      <div className="modal modal--wide" onClick={e => e.stopPropagation()}>
        <div className="modal__head">
          <h3>Create {orders.length === 1 ? "label" : `${orders.length} labels`} — Royal Mail</h3>
          <button className="icon-btn" onClick={onClose}><Icon name="x" /></button>
        </div>
        <div className="modal__body">
          <div className="ship-applyall">
            <span className="kick">Apply to all:</span>
            <input className="rm-in" type="number" placeholder="weight g" onChange={e => applyAll({ weight: e.target.value })} />
            <button type="button" className="ptype-btn ptype-btn--sm" onClick={() => applyAllType("box")} title="Set all to Box"><Icon name="box" /><span>Box</span></button>
            <button type="button" className="ptype-btn ptype-btn--sm" onClick={() => applyAllType("envelope")} title="Set all to Envelope"><Icon name="mail" /><span>Envelope</span></button>
          </div>
          <table className="rm-svc ship-tbl">
            <thead><tr><th>Order</th><th>Ship to</th><th>Weight (g)</th><th>Parcel &amp; Service</th><th>Customs / HS</th></tr></thead>
            <tbody>
              {orders.map((o, i) => {
                const ai = addrIssues(o); const intl = isIntl(o); const ni = isNI(o); const customs = intl || ni;
                const kids = (groupChildren && groupChildren[o.id]) || [];
                // UK orders don't need international services in the picker (and vice versa).
                const svcForRow = intl
                  ? services
                  : services.filter(s => !/international/i.test(s.name) && !["OTA", "MPR"].includes((s.code || "").toUpperCase()));
                // Dispatch overdue? (fulfil-by date already passed.) If so, recommend
                // bumping to the next-faster service to claw back the lost day.
                const daysLate = o.fulfillBy ? Math.round((parseYMD(todayStr()) - parseYMD(o.fulfillBy)) / 864e5) : 0;
                const late = daysLate > 0;
                const upgradeId = late ? fasterService(svcForRow, rows[i].serviceId) : null;
                const upgradeSvc = upgradeId && services.find(s => s.id === upgradeId);
                // "Changed" = the picked TIER differs from the requested one (a
                // Box↔Envelope swap at the same tier is not a change).
                const _reqTier = svcTier((services.find(s => s.id === rows[i].requestedServiceId) || {}).name);
                const _curTier = svcTier((services.find(s => s.id === rows[i].serviceId) || {}).name);
                const svcChanged = _reqTier && _curTier && _reqTier !== _curTier;
                return (
                  <tr key={o.id}>
                    <td className="mono">{o.id}{intl ? <span className="badge badge--amber" style={{ marginLeft: 4 }}>INTL</span> : ni ? <span className="badge badge--amber" style={{ marginLeft: 4 }} title="Northern Ireland — needs customs (HS) data for the label">NI</span> : null}</td>
                    <td style={{ maxWidth: 220 }}>
                      <div style={{ fontSize: 11, fontWeight: 600 }}>{(o.ship && o.ship.name) || "—"}</div>
                      {ai.length
                        ? <div style={{ color: "#c0392b", fontSize: 10.5 }}>⚠ missing {ai.join(", ")}</div>
                        : <div className="dim" style={{ fontSize: 10.5 }}>✓ {(o.ship && (o.ship.city + ", " + (o.ship.countryCodeV2 || ""))) || ""}</div>}
                      {orderLineSummary(o) && (
                        <div className="ship-lines" title={orderLineSummary(o)}>{orderLineSummary(o)}</div>
                      )}
                      {kids.length > 0 && (
                        <div className="combined-note" title="These child orders ride in this parcel and are dispatched in Shopify with this label's tracking (no separate customer email).">
                          <Icon name="link" /> Combined: {kids.map(k => k.id).join(", ")}
                        </div>
                      )}
                    </td>
                    <td><input className="rm-in" type="number" value={rows[i].weight} onChange={e => set(i, { weight: e.target.value })} placeholder="g" /></td>
                    <td>
                      {late && upgradeSvc && (
                        <div className="late-rec">
                          <div className="late-rec__hd">
                            <Icon name="spark" /> Dispatch overdue — due {o.fulfillBy} ({daysLate} day{daysLate===1?"":"s"} late)
                          </div>
                          <div className="late-rec__body">
                            Recommend upgrading to <b>{svcTier(upgradeSvc.name) || upgradeSvc.name}</b> ({upgradeSvc.code}) to recover time.
                            <button type="button" className="late-rec__btn" onClick={() => setParcel(i, { serviceId: upgradeId })}>
                              Upgrade → {svcTier(upgradeSvc.name) || upgradeSvc.code}
                            </button>
                          </div>
                        </div>
                      )}
                      {o.shippingTitle && (
                        <div className={"req-svc" + (svcChanged ? " req-svc--deviated" : "")}
                             title="Service the customer requested at checkout">
                          <span className="req-svc__k">Requested:</span> {o.shippingTitle}
                          {rows[i].mappedFromRequest && !svcChanged && <span className="req-svc__map" title="Auto-selected from your saved mapping">✓ mapped</span>}
                          {svcChanged && <span className="req-svc__diff" title="You picked a different service tier than requested">changed</span>}
                        </div>
                      )}
                      <ParcelTypePicker services={svcForRow} serviceId={rows[i].serviceId} format={rows[i].format}
                        requestedServiceId={rows[i].requestedServiceId}
                        onChange={patch => setParcel(i, patch)} />
                      {o.customerId && (
                        <label className="dim" style={{ display: "flex", alignItems: "center", gap: 4, fontSize: 10, marginTop: 5, cursor: "pointer" }} title="Remember this service + parcel as this customer's default">
                          <input type="checkbox" checked={!!rows[i].saveDefault} onChange={e => set(i, { saveDefault: e.target.checked })} />
                          {rows[i].hadDefault ? "update customer default" : "save as customer default"}
                        </label>
                      )}
                    </td>
                    <td>{customs
                      ? <div>
                          <div style={{ display: "flex", gap: 4, flexWrap: "wrap" }}>
                            <input className="rm-in" style={{ width: 76 }} value={rows[i].hs} onChange={e => set(i, { hs: e.target.value })} placeholder="HS code" />
                            <input className="rm-in" style={{ width: 110 }} value={rows[i].hsDesc} onChange={e => set(i, { hsDesc: e.target.value })} placeholder="description" />
                            <input className="rm-in" style={{ width: 64 }} type="number" value={rows[i].hsValue} onChange={e => set(i, { hsValue: e.target.value })} placeholder="value £" />
                          </div>
                          {/* Windsor Framework: GB→NI (and intl) MUST declare whether the recipient
                              is a business. Royal Mail won't apply postage without it. Default No. */}
                          <label style={{ display: "flex", alignItems: "center", gap: 6, marginTop: 5, fontSize: 11 }}
                                 title="Royal Mail requires this on GB→Northern Ireland and international customs orders. Choose Yes if shipping to a business (B2B).">
                            <span style={{ fontWeight: 600 }}>Recipient is a business</span>
                            <select className="rm-in" style={{ width: 66 }} value={rows[i].isBusiness ? "yes" : "no"}
                                    onChange={e => set(i, { isBusiness: e.target.value === "yes" })}>
                              <option value="no">No</option>
                              <option value="yes">Yes</option>
                            </select>
                          </label>
                          <div className="dim" style={{ fontSize: 10, marginTop: 3 }}>{ni ? "GB→NI needs customs data — prefilled for prints, saved per product" : "Saved per product for next time"}</div>
                        </div>
                      : <span className="dim" style={{ fontSize: 11 }}>n/a (UK)</span>}</td>
                  </tr>
                );
              })}
            </tbody>
          </table>
          {err && <div className="warn-banner" style={{ marginTop: 12 }}><Icon name="spark" /> {err}</div>}
        </div>
        <div className="modal__foot">
          <div style={{ display: "flex", flexDirection: "column", gap: 4 }}>
            <label className="kick" style={{ display: "flex", alignItems: "center", gap: 6, cursor: "pointer" }} title="Mark the order fulfilled in Shopify with this tracking and email the customer">
              <input type="checkbox" checked={dispatch} onChange={e => setDispatch(e.target.checked)} />
              Mark dispatched in Shopify &amp; notify customer
            </label>
            <label className="kick" style={{ display: "flex", alignItems: "center", gap: 6 }} title="Print silently to a packing-station printer, or open the PDF to print manually">
              <Icon name="print" /> Print to:
              <select className="rm-in rm-in--sm" value={printTo}
                onChange={e => { setPrintTo(e.target.value); try { localStorage.setItem("dtf_labelStation", e.target.value); } catch {} }}>
                {agents.map(a => <option key={a.id} value={a.id}>{a.name} (direct print)</option>)}
                <option value="browser">Open PDF (this browser)</option>
              </select>
            </label>
            <span className="kick">Generating labels buys postage on your OBA account.</span>
          </div>
          <div style={{ display: "flex", gap: 8 }}>
            <button className="btn btn--ghost btn--sm" onClick={onClose}>Cancel</button>
            <button className="btn btn--primary btn--sm" disabled={busy} onClick={create}>{busy ? "Creating…" : (dispatch ? "Create, print & dispatch" : "Create & print labels")}</button>
          </div>
        </div>
      </div>
    </div>
  );
}
window.ShipModal = ShipModal;
