/* ═══════════════════════════════════════════════════════════════════
   FileWizardSteps.jsx — the SHARED, reusable wizard steps (§1 "build
   once, reuse") consumed by BOTH the Convert and Organize configs in
   FileManager. Per _FILE-MANAGER-DUAL-PATH-AND-WIZARD-SPEC.md:
     • Source      — bulk/single upload, any allowed media type
     • Metadata    — type-aware (rename all; alt/SEO images; title/desc/keywords docs)
     • Destination — the lifted mapping picker (site -> organizer -> folder + cats/tags)
     • BatchBoard  — per-batch multi-location (decision #2: 1 batch = 1 destination,
                     applied to every file in it; boundary must be unmistakable)
     • Review      — per-batch summary

   DESIGN: steps only COLLECT state into the wizard data bag. The actual
   upload + enqueue runs in each path's onCommit (engine's deferred atomic
   commit) using path-specific endpoints — so these steps are 100%
   contract-agnostic and shared. Mapping output is the locked route shape
   { destination, folder_id, category_ids, tag_ids } that normalizeRoute()
   + the plugin route_file already consume.

   Load AFTER the main app Icon set + WizardPrimitives/Shell/Runner.
   Registers: window.WPSB.FileWizard.{ SourceStep, MetadataStep,
     DestinationStep, BatchBoard, ReviewStep, useDestinations,
     uploadAndStage, putToSignedUrlXHR, fmtBytes, makeApi }

   Pattern: IIFE, React.createElement (pure ASCII), CSS vars only.
   ═══════════════════════════════════════════════════════════════════ */
(function () {
  const { useState, useEffect, useRef, useCallback } = React;
  const h = React.createElement;
  const tx = (k, v, f) => (window.WPSB && window.WPSB.t) ? window.WPSB.t(k, v, f) : (f != null ? f : k);

  const Icon = window.Icon || function (p) { return h('span', { 'aria-hidden': true }); };
  const NoticeCard = window.NoticeCard;

  const RAILWAY_URL = (window.WPSB_CONFIG && window.WPSB_CONFIG.RAILWAY_URL)
    || 'https://wpsitebeam-railway-api-production.up.railway.app';

  /* ── tiny self-contained API client (mirrors FileManager/ImageBgRemove) ── */
  function makeApi() {
    function getFallbackToken() {
      return (window.WPSB && window.WPSB.getToken && window.WPSB.getToken())
        || localStorage.getItem('wpsb-auth-token') || null;
    }
    return async function api(path, opts) {
      opts = opts || {};
      const init = { method: opts.method || 'GET', headers: {} };
      if (opts.body !== undefined) { init.headers['Content-Type'] = 'application/json'; init.body = JSON.stringify(opts.body); }
      let resp;
      try {
        if (window.WPSB_Auth && typeof window.WPSB_Auth.authedFetch === 'function') {
          resp = await window.WPSB_Auth.authedFetch(RAILWAY_URL + path, init);
        } else {
          const t = getFallbackToken();
          if (t) init.headers['Authorization'] = 'Bearer ' + t;
          resp = await fetch(RAILWAY_URL + path, init);
        }
      } catch (_) { return { ok: false, status: 0, code: 'NETWORK', error: tx('page.filewizard.err_network', null, 'Network error - could not reach the server.') }; }
      let data = null; try { data = await resp.json(); } catch (_) { data = null; }
      if (!resp.ok) return { ok: false, status: resp.status, code: (data && data.code) || 'ERROR', error: (data && data.error) || ('Request failed (' + resp.status + ')'), data: data };
      return { ok: true, status: resp.status, data: data || {} };
    };
  }
  const _api = makeApi();

  /* signed-URL PUT with progress (xhr for upload events) */
  function putToSignedUrlXHR(uploadUrl, file, onProgress) {
    return new Promise(function (resolve, reject) {
      const xhr = new XMLHttpRequest();
      xhr.open('PUT', uploadUrl, true);
      xhr.setRequestHeader('Content-Type', file.type || 'application/octet-stream');
      // Supabase signed-upload PUT requires x-upsert (matches FileManager.jsx /
      // ImageBgRemoveWizard.jsx against the same file-conversions bucket); without
      // it the direct-to-Supabase PUT is rejected 400. Fresh-UUID paths never overwrite.
      xhr.setRequestHeader('x-upsert', 'true');
      xhr.upload.onprogress = function (e) { if (e.lengthComputable && onProgress) onProgress(e.loaded / e.total); };
      xhr.onload = function () { (xhr.status >= 200 && xhr.status < 300) ? resolve(true) : reject(new Error('Upload failed (' + xhr.status + ')')); };
      xhr.onerror = function () { reject(new Error(tx('page.filewizard.err_upload_network', null, 'Upload network error'))); };
      xhr.send(file);
    });
  }

  /* shared commit util: mint signed URLs -> PUT bytes -> return staged refs.
     uploadsEndpoint differs per path (/convert/uploads | /organize/uploads). */
  async function uploadAndStage(api, uploadsEndpoint, files, onProgress) {
    const up = await api(uploadsEndpoint, { method: 'POST', body: { files: files.map(function (f) { return { name: f.name, type: f.type || '' }; }) } });
    if (!up.ok) {
      if (up.code === 'PLAN_UPGRADE_REQUIRED') throw new Error(up.error || tx('page.filewizard.err_plan_upgrade', null, 'This is a paid-plan feature. Upgrade to unlock it.'));
      throw new Error(up.error || tx('page.filewizard.err_no_upload_urls', null, 'Could not get upload URLs'));
    }
    const minted = up.data.files || [];
    const batch_id = up.data.batch_id;
    for (let i = 0; i < minted.length; i++) {
      await putToSignedUrlXHR(minted[i].upload_url, files[i], function (frac) {
        if (onProgress) onProgress(i, frac, minted.length);
      });
    }
    return { batch_id: batch_id, staged: minted.map(function (m) { return { source_path: m.source_path, source_name: m.source_name }; }) };
  }

  function fmtBytes(n) {
    if (!n && n !== 0) return '';
    if (n < 1024) return n + ' B';
    if (n < 1048576) return (n / 1024).toFixed(0) + ' KB';
    return (n / 1048576).toFixed(1) + ' MB';
  }
  function isImage(f) { return /^image\//.test(f.type || '') || /\.(png|jpe?g|gif|webp|avif|svg)$/i.test(f.name || ''); }
  function isDocLike(f) { return /\.(docx?|pptx?|xlsx?|pdf|odt|rtf|txt|csv)$/i.test(f.name || '') || /(pdf|word|presentation|sheet|document)/.test(f.type || ''); }

  /* ── destination data: /convert/destinations (sites + routing_enabled) ── */
  function useDestinations(api) {
    const [state, setState] = useState({ loading: true, routing: false, sites: [] });
    const load = useCallback(async function () {
      setState(function (s) { return Object.assign({}, s, { loading: true }); });
      const r = await api('/convert/destinations');
      if (r.ok && r.data) setState({ loading: false, routing: !!r.data.routing_enabled, sites: r.data.sites || [] });
      else setState({ loading: false, routing: false, sites: [] });
    }, [api]);
    useEffect(function () { load(); }, [load]);
    return { state: state, reload: load };
  }

  // A site is offerable as a writable route target only when the server says it's `eligible`
  // (is_active + fresh heartbeat + reported destinations — API >= v1.59). Pre-eligibility APIs
  // don't send the field, so fall back to showing every active site (no behavior change).
  function siteIsEligible(s) { return s ? (('eligible' in s) ? !!s.eligible : true) : false; }
  function eligibleSites(sites) {
    const list = Array.isArray(sites) ? sites : [];
    if (!list.some(function (s) { return s && ('eligible' in s); })) return list; // old API → all
    return list.filter(siteIsEligible);
  }
  function siteHealthNote(s) {
    switch (s && s.health) {
      case 'stale':           return tx('page.filewizard.health_stale', null, 'This site hasn’t checked in recently. Open WPSiteBeam → Connection → Re-sync on the site, then Re-detect here, before routing.');
      case 'never_connected': return tx('page.filewizard.health_never_connected', null, 'This site has never completed a connection — finish plugin setup on the site before routing.');
      case 'no_destinations': return tx('page.filewizard.health_no_destinations', null, 'This site hasn’t reported its folders/libraries yet — Re-sync on the site, then Re-detect.');
      case 'inactive':        return tx('page.filewizard.health_inactive', null, 'This site is disconnected.');
      default:                return tx('page.filewizard.health_default', null, 'This site isn’t currently connected.');
    }
  }

  function foldersWithDepth(folders) {
    const list = Array.isArray(folders) ? folders : [];
    const byId = {}; list.forEach(function (f) { if (f && f.id != null) byId[f.id] = f; });
    const depthOf = function (f) { let d = 0, cur = f, g = 0; while (cur && cur.parent && byId[cur.parent] && g++ < 25) { d++; cur = byId[cur.parent]; } return d; };
    return list.map(function (f) { return Object.assign({}, f, { depth: depthOf(f) }); });
  }

  // Order a hierarchical taxonomy (DLP doc_categories) parent -> child and stamp __depth,
  // so the checkbox list + parent dropdown render WP-admin-style indented (a child term sits
  // under its parent). Orphans (parent id not in the set) fall back to depth 0 so nothing is
  // dropped. Incoming order is preserved within each parent (plugin returns name-asc terms).
  function termsTree(terms) {
    const list = Array.isArray(terms) ? terms : [];
    const byId = {}; list.forEach(function (t) { if (t && t.id != null) byId[Number(t.id)] = t; });
    const kids = {};
    list.forEach(function (t) {
      const p = (t && t.parent && byId[Number(t.parent)]) ? Number(t.parent) : 0;
      (kids[p] = kids[p] || []).push(t);
    });
    const out = [];
    (function walk(parentId, depth) {
      (kids[parentId] || []).forEach(function (t) {
        out.push(Object.assign({}, t, { __depth: depth }));
        if (depth < 25) walk(Number(t.id), depth + 1);
      });
    })(0, 0);
    if (out.length < list.length) {
      const seen = {}; out.forEach(function (t) { seen[Number(t.id)] = 1; });
      list.forEach(function (t) { if (!seen[Number(t.id)]) out.push(Object.assign({}, t, { __depth: 0 })); });
    }
    return out;
  }

  const lbl = { fontSize: '.58rem', color: 'var(--muted)', textTransform: 'uppercase', letterSpacing: 1 };
  const selStyle = { width: '100%', maxWidth: 340, padding: '7px 10px', borderRadius: 8, background: 'var(--surface-3)', border: '1px solid var(--border)', color: 'var(--text)', fontSize: '.8rem' };

  function TermCheckList({ terms, selected, onToggle, empty }) {
    const list = Array.isArray(terms) ? terms : [];
    if (!list.length) return h('div', { style: { fontSize: '.7rem', color: 'var(--dim)' } }, empty);
    const sel = new Set((selected || []).map(Number));
    return h('div', { style: { display: 'flex', flexDirection: 'column', gap: 2, maxHeight: 150, overflowY: 'auto', border: '1px solid var(--border)', borderRadius: 8, padding: '6px 8px', background: 'var(--surface-3)' } },
      list.map(function (t) {
        // __depth (set by termsTree for hierarchical categories) indents children under their
        // parent, WP-admin style. Flat taxonomies (tags) have no __depth -> zero indent.
        const depth = Number(t.__depth) || 0;
        return h('label', { key: t.id, style: { display: 'flex', alignItems: 'center', gap: 7, fontSize: '.76rem', color: 'var(--text)', cursor: 'pointer', padding: '2px 0', paddingLeft: depth * 16 } },
          h('input', { type: 'checkbox', checked: sel.has(Number(t.id)), onChange: function () { onToggle(Number(t.id)); } }),
          depth > 0 && h('span', { 'aria-hidden': true, style: { color: 'var(--dim)', flex: '0 0 auto' } }, '—'),
          h('span', { style: { overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' } }, t.name)
        );
      })
    );
  }

  /* ── the mapping picker (lifted from FileManager.DestinationPlacement, self-contained) ──
     value shape: { destination, folder_id, category_ids[], tag_ids[] } */
  function InlineCreate({ placeholder, onCreate }) {
    const [name, setName] = useState('');
    const submit = function () {
      const v = (name || '').trim();
      if (!v) return;
      onCreate(v.slice(0, 120));
      setName('');
    };
    return h('div', { style: { display: 'flex', gap: 6, marginTop: 6 } },
      h('input', {
        value: name, placeholder: placeholder, maxLength: 120,
        onChange: function (e) { setName(e.target.value); },
        onKeyDown: function (e) { if (e.key === 'Enter') { e.preventDefault(); submit(); } },
        style: Object.assign({}, selStyle, { maxWidth: 240 }),
      }),
      h('button', { type: 'button', className: 'btn btn-ghost btn-sm', onClick: submit, disabled: !name.trim() }, tx('page.filewizard.create', null, 'Create'))
    );
  }

  function PendingChip({ label, onRemove }) {
    return h('span', { style: { display: 'inline-flex', alignItems: 'center', gap: 6, fontSize: '.72rem', color: 'var(--text)', background: 'var(--beam-dim)', border: '1px solid var(--beam)', borderRadius: 999, padding: '2px 8px' } },
      h('span', {}, tx('page.filewizard.chip_new_prefix', { label: label }, 'New: {label}')),
      h('button', { type: 'button', 'aria-label': tx('page.filewizard.remove_x', { name: label }, 'Remove {name}'), onClick: onRemove, style: { background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text)', padding: 0, lineHeight: 1, display: 'inline-flex' } }, h(Icon, { name: 'x', size: 10 }))
    );
  }

  /* Mapping picker. value shape: { destination, folder_id, create?:{name,parent},
     category_ids[], tag_ids[], new_categories[], new_tags[] } - create fields are gated
     server-side by normalizeRoute() and executed (create-or-get) by the plugin adapter. */
  function PlacementPicker({ site, value, onChange }) {
    // Parent for a NEW DLP child category (0 = create a top-level category). Hook stays
    // unconditional (rules-of-hooks); only consumed by the DLP block below.
    const [catParent, setCatParent] = React.useState(0);
    const tier1 = (site && Array.isArray(site.tier1)) ? site.tier1 : [];
    const organizers = tier1.filter(function (o) { return o && o.available; });
    const rmlInactive = tier1.find(function (o) { return o && o.destination === 'rml' && !o.available && (o.reason === 'not_activated' || o.activated === false); }) || null;
    const dest = (value && value.destination) || 'media_library';
    const setDest = function (d) {
      if (d === 'dlp') return onChange({ destination: 'dlp', category_ids: [], tag_ids: [], new_categories: [], new_child_categories: [], new_tags: [] });
      if (d === 'media_library') return onChange({ destination: 'media_library' });
      return onChange({ destination: d, folder_id: 0 });
    };
    const toggle = function (key, id) {
      const cur = new Set((((value && value[key]) || [])).map(Number));
      if (cur.has(id)) cur.delete(id); else cur.add(id);
      onChange(Object.assign({}, value, { [key]: Array.from(cur) }));
    };
    const addName = function (key, name) {
      const cur = (value && value[key]) || [];
      if (cur.indexOf(name) >= 0) return;
      onChange(Object.assign({}, value, { [key]: cur.concat(name) }));
    };
    const removeName = function (key, name) {
      const cur = (value && value[key]) || [];
      onChange(Object.assign({}, value, { [key]: cur.filter(function (n) { return n !== name; }) }));
    };
    const inactiveNotice = rmlInactive && h('div', { style: { fontSize: '.7rem', color: 'var(--warn)', lineHeight: 1.5, marginTop: 2 } },
      h('strong', {}, tx('page.filewizard.rml_name', null, 'Real Media Library')), tx('page.filewizard.rml_inactive_body', null, ' is installed on the site but not activated, so it can’t receive files yet. Activate it in the site’s WP admin (the Real Media Library consent prompt), then Re-detect.'));

    if (!organizers.length) {
      return h('div', { style: { display: 'flex', flexDirection: 'column', gap: 8 } },
        h('div', { style: { fontSize: '.72rem', color: 'var(--dim)', lineHeight: 1.5 } },
          tx('page.filewizard.lands_prefix', null, 'Lands in this site’s '), h('strong', { style: { color: 'var(--text)' } }, tx('page.filewizard.media_library', null, 'Media Library')), tx('page.filewizard.lands_suffix', null, ' (flat). Install a media-organizer plugin (FileBird, Real Media Library, Folders, or Document Library Pro) on the site to route into folders, categories, and tags — then Re-detect.')),
        inactiveNotice
      );
    }
    const activeOrg = organizers.find(function (o) { return o.destination === dest; }) || null;
    const newCats = (value && value.new_categories) || [];
    const newTags = (value && value.new_tags) || [];
    const createObj = value && value.create;

    return h('div', { style: { display: 'flex', flexDirection: 'column', gap: 8 } },
      h('span', { className: 'mono', style: lbl }, tx('page.filewizard.placement', null, 'Placement')),
      h('select', { value: dest, onChange: function (e) { setDest(e.target.value); }, style: selStyle },
        h('option', { value: 'media_library' }, tx('page.filewizard.media_library_flat', null, 'Media Library (flat)')),
        organizers.map(function (o) { return h('option', { key: o.destination, value: o.destination }, o.label); })
      ),
      inactiveNotice,

      activeOrg && activeOrg.destination === 'dlp' && (function () {
        const cats = Array.isArray(activeOrg.categories) ? activeOrg.categories : [];
        // doc_categories is hierarchical — order parent->child + stamp depth so the checklist
        // and parent dropdown render the real tree (WP-admin convention) instead of flat.
        const catsTree = termsTree(cats);
        const catName = function (id) { const c = cats.find(function (x) { return Number(x.id) === Number(id); }); return c ? c.name : ('#' + id); };
        const newChildCats = (value && value.new_child_categories) || [];
        const addChild = function (name, parent) {
          const cur = (value && value.new_child_categories) || [];
          if (cur.some(function (c) { return c.name === name && Number(c.parent) === Number(parent); })) return;
          onChange(Object.assign({}, value, { new_child_categories: cur.concat({ name: name, parent: Number(parent) }) }));
        };
        const removeChild = function (name, parent) {
          const cur = (value && value.new_child_categories) || [];
          onChange(Object.assign({}, value, { new_child_categories: cur.filter(function (c) { return !(c.name === name && Number(c.parent) === Number(parent)); }) }));
        };
        const onCreateCat = function (n) { if (catParent) addChild(n, catParent); else addName('new_categories', n); };
        // Tags are flat: surface the most-used as a tappable quick-pick row, and order the
        // full list by popularity (count, desc) so common tags are reachable first.
        const tagList = Array.isArray(activeOrg.tags) ? activeOrg.tags : [];
        const tagSel = new Set(((value && value.tag_ids) || []).map(Number));
        const byCount = function (a, b) { return (Number(b.count) || 0) - (Number(a.count) || 0); };
        const tagsByCount = tagList.slice().sort(byCount);
        const mostUsed = tagList.filter(function (t) { return (Number(t.count) || 0) > 0; }).slice().sort(byCount).slice(0, 8);
        return h('div', { style: { display: 'flex', flexDirection: 'column', gap: 8 } },
          h('div', { style: { display: 'flex', flexDirection: 'column', gap: 4 } },
            h('span', { className: 'mono', style: lbl }, tx('page.filewizard.categories', null, 'Categories')),
            h(TermCheckList, { terms: catsTree, selected: value && value.category_ids, onToggle: function (id) { toggle('category_ids', id); }, empty: tx('page.filewizard.no_categories', null, 'No categories on this site yet — create one below.') }),
            (newCats.length > 0 || newChildCats.length > 0) && h('div', { style: { display: 'flex', flexWrap: 'wrap', gap: 6, marginTop: 4 } },
              newCats.map(function (n) { return h(PendingChip, { key: 'nc-' + n, label: tx('page.filewizard.chip_new', { name: n }, '{name} (new)'), onRemove: function () { removeName('new_categories', n); } }); }).concat(
              newChildCats.map(function (c) { return h(PendingChip, { key: 'ncc-' + c.parent + '-' + c.name, label: tx('page.filewizard.new_child_chip', { name: c.name, parent: catName(c.parent) }, 'New child: {name} under {parent}'), onRemove: function () { removeChild(c.name, c.parent); } }); }))),
            cats.length > 0 && h('label', { style: { fontSize: '.66rem', color: 'var(--dim)', display: 'flex', flexDirection: 'column', gap: 2, marginTop: 2 } },
              tx('page.filewizard.parent_for_child', null, 'Parent (for a new child category)'),
              h('select', { value: catParent, onChange: function (e) { setCatParent(Number(e.target.value)); }, style: selStyle },
                h('option', { value: 0 }, tx('page.filewizard.none_top_level', null, '— none (new top-level category) —')),
                catsTree.map(function (c) { return h('option', { key: c.id, value: c.id }, (Number(c.__depth) > 0 ? '— '.repeat(Number(c.__depth)) : '') + c.name); }))),
            h(InlineCreate, { placeholder: catParent ? tx('page.filewizard.new_child_under_ph', { parent: catName(catParent) }, 'New child under {parent}…') : tx('page.filewizard.new_top_category_ph', null, 'New top-level category…'), onCreate: onCreateCat })
          ),
          h('div', { style: { display: 'flex', flexDirection: 'column', gap: 4 } },
            h('span', { className: 'mono', style: lbl }, tx('page.filewizard.tags', null, 'Tags')),
            mostUsed.length > 0 && h('div', { style: { display: 'flex', flexDirection: 'column', gap: 3 } },
              h('span', { style: { fontSize: '.6rem', color: 'var(--muted)', textTransform: 'uppercase', letterSpacing: 1 } }, tx('page.filewizard.most_used', null, 'Most used')),
              h('div', { role: 'group', 'aria-label': tx('page.filewizard.most_used_tags', null, 'Most used tags'), style: { display: 'flex', flexWrap: 'wrap', gap: 6 } },
                mostUsed.map(function (t) {
                  const on = tagSel.has(Number(t.id));
                  return h('button', { key: 'mu-' + t.id, type: 'button', 'aria-pressed': on, onClick: function () { toggle('tag_ids', Number(t.id)); },
                    style: { display: 'inline-flex', alignItems: 'center', gap: 5, fontSize: '.72rem', cursor: 'pointer', borderRadius: 999, padding: '2px 9px', font: 'inherit',
                      color: on ? 'var(--text)' : 'var(--text-2)', background: on ? 'var(--beam-dim)' : 'var(--surface-3)', border: '1px solid ' + (on ? 'var(--beam)' : 'var(--border)') } },
                    on && h(Icon, { name: 'check', size: 10 }),
                    h('span', {}, t.name),
                    h('span', { style: { color: 'var(--dim)', fontSize: '.64rem' } }, String(Number(t.count) || 0))
                  );
                }))),
            h(TermCheckList, { terms: tagsByCount, selected: value && value.tag_ids, onToggle: function (id) { toggle('tag_ids', id); }, empty: tx('page.filewizard.no_tags', null, 'No tags on this site yet — create one below.') }),
            newTags.length > 0 && h('div', { style: { display: 'flex', flexWrap: 'wrap', gap: 6, marginTop: 4 } },
              newTags.map(function (n) { return h(PendingChip, { key: 'nt-' + n, label: tx('page.filewizard.chip_new', { name: n }, '{name} (new)'), onRemove: function () { removeName('new_tags', n); } }); })),
            h(InlineCreate, { placeholder: tx('page.filewizard.new_tag_ph', null, 'New tag name…'), onCreate: function (n) { addName('new_tags', n); } })
          )
        );
      })(),

      activeOrg && activeOrg.destination !== 'dlp' && (function () {
        const allFolders = Array.isArray(activeOrg.folders) ? activeOrg.folders : [];
        const nameById = function (id) { const f = allFolders.find(function (x) { return Number(x.id) === Number(id); }); return f ? f.name : null; };
        const selId = (value && value.folder_id) || 0;
        const selName = nameById(selId);
        const folders = foldersWithDepth(activeOrg.folders);
        const createParentName = (createObj && createObj.parent) ? nameById(createObj.parent) : null;
        return h('div', { style: { display: 'flex', flexDirection: 'column', gap: 4 } },
          h('span', { className: 'mono', style: lbl }, tx('page.filewizard.folder', null, 'Folder')),
          folders.length
            ? h('select', { value: selId, onChange: function (e) { onChange(Object.assign({}, value, { folder_id: Number(e.target.value) })); }, style: selStyle },
                h('option', { value: 0 }, tx('page.filewizard.organizer_root', null, 'Organizer root')),
                folders.map(function (f) { return h('option', { key: f.id, value: f.id }, '  '.repeat(f.depth) + f.name); }))
            : h('div', { style: { fontSize: '.7rem', color: 'var(--dim)' } }, tx('page.filewizard.no_folders', null, 'No folders on this site yet \u2014 create one below, or files land at the organizer root.')),
          createObj && createObj.name
            ? h('div', { style: { marginTop: 4 } },
                h(PendingChip, { label: createParentName ? tx('page.filewizard.folder_chip_under', { name: createObj.name, parent: createParentName }, '{name} (under {parent})') : tx('page.filewizard.folder_chip_top', { name: createObj.name }, '{name} (top level)'), onRemove: function () { const nv = Object.assign({}, value); delete nv.create; onChange(nv); } }))
            : h(InlineCreate, { placeholder: selId ? tx('page.filewizard.new_subfolder_ph', { name: selName }, 'New sub-folder under {name}\u2026') : tx('page.filewizard.new_top_folder_ph', null, 'New top-level folder\u2026'), onCreate: function (n) { onChange(Object.assign({}, value, { create: { name: n, parent: selId } })); } }),
          !createObj && h('div', { style: { fontSize: '.66rem', color: 'var(--dim)', lineHeight: 1.4, marginTop: 2 } }, tx('page.filewizard.folder_tip', null, 'Tip: pick a parent folder above to nest the new one under it, or leave at Organizer root for a top-level folder.'))
        );
      })()
    );
  }

  function SiteSelect({ sites, siteId, onPick }) {
    const all = Array.isArray(sites) ? sites : [];
    const elig = eligibleSites(all);
    const labelOf = function (s) { return s.name || s.hostname || s.site_url || s.id; };
    const current = siteId ? (all.find(function (s) { return String(s.id) === String(siteId); }) || null) : null;
    const currentIneligible = current && !siteIsEligible(current);
    return h('div', { style: { display: 'flex', flexDirection: 'column', gap: 4 } },
      h('span', { className: 'mono', style: lbl }, tx('page.filewizard.connected_site', null, 'Connected site')),
      h('select', { value: siteId || '', onChange: function (e) { onPick(e.target.value || null); }, style: selStyle },
        h('option', { value: '' }, elig.length ? tx('page.filewizard.select_a_site', null, 'Select a site\u2026') : tx('page.filewizard.no_connected_site_available', null, 'No connected site available')),
        elig.map(function (s) { return h('option', { key: s.id, value: s.id }, labelOf(s)); }),
        // A previously-selected site that has gone stale stays visible (disabled) so it is
        // never silently dropped \u2014 but it can't be used as a writable destination.
        currentIneligible ? h('option', { key: 'stale-' + current.id, value: current.id, disabled: true }, tx('page.filewizard.site_not_connected', { label: labelOf(current) }, '{label} \u2014 not connected')) : null
      ),
      currentIneligible ? h('div', { style: { fontSize: '.68rem', color: 'var(--warn)', lineHeight: 1.4 } }, siteHealthNote(current)) : null
    );
  }

  /* ════════════════════════ STEP: SOURCE (bulk upload — collect only) ═══ */
  function SourceStep({ ctx, accept, multiple, label, hint }) {
    const files = (ctx.data.files) || [];
    const inputRef = useRef(null);
    const [drag, setDrag] = useState(false);
    const add = function (list) {
      const incoming = Array.prototype.slice.call(list || []);
      if (!incoming.length) return;
      ctx.patch({ files: multiple === false ? incoming.slice(0, 1) : files.concat(incoming) });
    };
    const removeAt = function (i) { ctx.patch({ files: files.filter(function (_, j) { return j !== i; }) }); };
    return h('div', { style: { display: 'flex', flexDirection: 'column', gap: 'var(--sp-4)' } },
      h('div', {
        onDragOver: function (e) { e.preventDefault(); setDrag(true); },
        onDragLeave: function () { setDrag(false); },
        onDrop: function (e) { e.preventDefault(); setDrag(false); add(e.dataTransfer.files); },
        onClick: function () { inputRef.current && inputRef.current.click(); },
        style: {
          border: '1.5px dashed ' + (drag ? 'var(--beam)' : 'var(--border-2)'),
          background: drag ? 'var(--beam-dim)' : 'var(--surface-2)',
          borderRadius: 'var(--r-lg)', padding: 'var(--sp-7)', textAlign: 'center', cursor: 'pointer',
          transition: 'border-color 150ms ease, background 150ms ease',
        },
      },
        h(Icon, { name: 'upload', size: 26 }),
        h('div', { style: { fontWeight: 'var(--fw-label)', marginTop: 8, color: 'var(--text)' } }, label || tx('page.filewizard.drop_files', null, 'Drop files here or click to browse')),
        h('div', { style: { fontSize: '.78rem', color: 'var(--muted)', marginTop: 4 } }, hint || (multiple === false ? tx('page.filewizard.one_file', null, 'One file') : tx('page.filewizard.add_many_files', null, 'You can add many files at once'))),
        h('input', { ref: inputRef, type: 'file', multiple: multiple !== false, accept: accept || undefined, style: { display: 'none' }, onChange: function (e) { add(e.target.files); e.target.value = ''; } })
      ),
      files.length > 0 && h('div', { style: { display: 'flex', flexDirection: 'column', gap: 6 } },
        h('div', { className: 'mono', style: lbl }, files.length === 1 ? tx('page.filewizard.files_staged_one', { n: files.length }, '{n} file staged') : tx('page.filewizard.files_staged_many', { n: files.length }, '{n} files staged')),
        files.map(function (f, i) {
          return h('div', { key: i, style: { display: 'flex', alignItems: 'center', gap: 10, padding: '7px 10px', borderRadius: 'var(--r)', background: 'var(--surface-2)', border: '1px solid var(--border)' } },
            h(Icon, { name: isImage(f) ? 'image' : 'file-text', size: 14 }),
            h('span', { style: { flex: 1, minWidth: 0, fontSize: '.82rem', color: 'var(--text)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' } }, f.name),
            h('span', { style: { fontSize: '.72rem', color: 'var(--dim)' } }, fmtBytes(f.size)),
            h('button', { type: 'button', className: 'btn btn-ghost btn-sm', onClick: function (e) { e.stopPropagation(); removeAt(i); }, 'aria-label': tx('page.filewizard.remove_x', { name: f.name }, 'Remove {name}') }, h(Icon, { name: 'x', size: 12 }))
          );
        })
      )
    );
  }

  /* ════════════════════════ STEP: METADATA (type-aware, collect config) ═══
     Produces ctx.data.meta = {
       rename: { enabled, pattern, client, doctype },
       gen:    { alt, image_seo, title, description, keywords }   // per-field opt-in generation
     }
     Type gating mirrors generateDocMeta: alt/image-SEO only when images present;
     title/desc/keywords only when doc-like present. Never auto-renames without opt-in. */
  function MetadataStep({ ctx, presets }) {
    const files = ctx.data.files || [];
    const hasImages = files.some(isImage);
    const hasDocs = files.some(isDocLike);
    const meta = ctx.data.meta || { rename: { enabled: false, pattern: '', client: '', doctype: '' }, gen: {} };
    const setMeta = function (partial) { ctx.patch({ meta: Object.assign({}, meta, partial) }); };
    const setRename = function (partial) { setMeta({ rename: Object.assign({}, meta.rename, partial) }); };
    const setGen = function (k, v) { setMeta({ gen: Object.assign({}, meta.gen, { [k]: v }) }); };
    // Per-file metadata OVERRIDES (Option 2): batch gen toggles apply to all; expand any one
    // file to set/override its fields. Keyed by the file's index in ctx.data.files. Entered
    // values win over AI generation at route time; they flow into delivery + Details.
    const fieldsAll = meta.fields || {};
    const setField = function (idx, key, value) {
      const cur = Object.assign({}, fieldsAll[idx] || {});
      if (value === '') { delete cur[key]; } else { cur[key] = value; }
      const next = Object.assign({}, fieldsAll);
      if (Object.keys(cur).length) { next[idx] = cur; } else { delete next[idx]; }
      setMeta({ fields: next });
    };
    // Per-file override fields. `ph` is an e.g.-prefixed native placeholder (auto-clears on
    // input, never reads as a saved value). `gen` is the batch toggle this field echoes (null
    // = manual-only, no AI). `full` = full-width row; non-full fields pair 50/50 and collapse
    // to one column on narrow viewports (flex-wrap). `multi` = textarea.
    const FIELD_DEFS = [
      { key: 'title',           label: tx('page.filewizard.field.title.label', null, 'Title'),                ph: tx('page.filewizard.field.title.ph', null, 'e.g. Commission Meeting Minutes — Dec 9, 2024'),                                                              gen: 'title',     full: true },
      { key: 'alt',             label: tx('page.filewizard.field.alt.label', null, 'Alt text'),             ph: tx('page.filewizard.field.alt.ph', null, 'e.g. Cover page of the December 2024 commission minutes'),                                                          gen: 'alt',       full: false },
      { key: 'caption',         label: tx('page.filewizard.field.caption.label', null, 'Caption'),              ph: tx('page.filewizard.field.caption.ph', null, 'e.g. Minutes from the Dec 9, 2024 regular session'),                                                               gen: 'image_seo', full: false },
      { key: 'description',     label: tx('page.filewizard.field.description.label', null, 'Description'),          ph: tx('page.filewizard.field.description.ph', null, 'e.g. Full minutes of the December 9, 2024 commission meeting — roll call, agenda items, and recorded votes.'), gen: 'description', full: true, multi: true },
      { key: 'seo_description', label: tx('page.filewizard.field.seo_description.label', null, 'SEO meta description'), ph: tx('page.filewizard.field.seo_description.ph', null, 'e.g. December 2024 commission meeting minutes — agenda, votes, and resolutions.'),                             gen: null,        full: true, multi: true },
      { key: 'tags',            label: tx('page.filewizard.field.tags.label', null, 'Tags'),                 ph: tx('page.filewizard.field.tags.ph', null, 'e.g. minutes, 2024, december'),                                                                                    gen: null,        full: false },
      { key: 'keywords',        label: tx('page.filewizard.field.keywords.label', null, 'Keywords'),             ph: tx('page.filewizard.field.keywords.ph', null, 'e.g. commission, minutes, meeting, december'),                                                                     gen: null,        full: false },
    ];
    const [openIdx, setOpenIdx] = useState(null);
    const Toggle = window.WToggle;
    const genRow = function (k, title, benefit, show) {
      if (!show) return null;
      return h('div', { style: { display: 'flex', alignItems: 'flex-start', gap: 12, padding: '10px 12px', borderRadius: 'var(--r)', background: 'var(--surface-2)', border: '1px solid var(--border)' } },
        h('div', { style: { flex: 1, minWidth: 0 } },
          h('div', { style: { fontWeight: 'var(--fw-label)', fontSize: '.86rem', color: 'var(--text)' } }, title),
          h('div', { style: { fontSize: '.76rem', color: 'var(--text-2)', marginTop: 2, lineHeight: 1.4 } }, benefit)
        ),
        Toggle ? h(Toggle, { checked: !!meta.gen[k], onChange: function (v) { setGen(k, v); }, label: title }) : h('input', { type: 'checkbox', checked: !!meta.gen[k], onChange: function (e) { setGen(k, e.target.checked); } })
      );
    };
    return h('div', { style: { display: 'flex', flexDirection: 'column', gap: 'var(--sp-4)' } },
      /* rename — applies to all types */
      h('div', { style: { display: 'flex', alignItems: 'flex-start', gap: 12, padding: '12px', borderRadius: 'var(--r-lg)', background: 'var(--surface-2)', border: '1px solid var(--border)' } },
        h('div', { style: { flex: 1, minWidth: 0 } },
          h('div', { style: { display: 'flex', alignItems: 'center', gap: 8 } },
            h('span', { style: { fontWeight: 'var(--fw-label)', fontSize: '.9rem', color: 'var(--text)' } }, tx('page.filewizard.rename_title', null, 'Rename on the way in')),
            meta.rename.enabled && h('span', { className: 'mono', style: { fontSize: '.6rem', color: 'var(--beam)' } }, tx('page.filewizard.rename_pattern_badge', null, 'pattern'))
          ),
          h('div', { style: { fontSize: '.78rem', color: 'var(--text-2)', marginTop: 2 } }, tx('page.filewizard.rename_desc', null, 'Apply a naming pattern to every file. Off = keep original names.')),
          meta.rename.enabled && h('div', { style: { marginTop: 10, display: 'flex', flexWrap: 'wrap', gap: 8 } },
            h('input', { value: meta.rename.pattern, placeholder: '{client}-{doctype}-{n}', onChange: function (e) { setRename({ pattern: e.target.value }); }, style: Object.assign({}, selStyle, { maxWidth: 260 }) }),
            /\{client\}/.test(meta.rename.pattern) && h('input', { value: meta.rename.client, placeholder: 'client', onChange: function (e) { setRename({ client: e.target.value }); }, style: Object.assign({}, selStyle, { maxWidth: 140 }) }),
            /\{doctype\}/.test(meta.rename.pattern) && h('input', { value: meta.rename.doctype, placeholder: 'doctype', onChange: function (e) { setRename({ doctype: e.target.value }); }, style: Object.assign({}, selStyle, { maxWidth: 140 }) })
          )
        ),
        Toggle ? h(Toggle, { checked: !!meta.rename.enabled, onChange: function (v) { setRename({ enabled: v }); }, label: tx('page.filewizard.rename_toggle_label', null, 'Rename files') }) : h('input', { type: 'checkbox', checked: !!meta.rename.enabled, onChange: function (e) { setRename({ enabled: e.target.checked }); } })
      ),
      // ── AI Metadata — batch toggles + per-file override under ONE labeled container so the
      //    relationship is legible: a blank field whose toggle is ON is AI-filled at processing
      //    time; anything you type wins. No button — generation stays at route time (Option 2).
      (hasImages || hasDocs) && h('div', { style: { display: 'flex', flexDirection: 'column', gap: 'var(--sp-3, 10px)', padding: '12px', borderRadius: 'var(--r-lg)', background: 'var(--surface-2)', border: '1px solid var(--border)' } },
        h('div', { style: { display: 'flex', flexDirection: 'column', gap: 2 } },
          h('span', { style: { fontWeight: 'var(--fw-label)', fontSize: '.9rem', color: 'var(--text)' } }, tx('page.filewizard.ai_metadata', null, 'AI Metadata')),
          h('div', { style: { fontSize: '.76rem', color: 'var(--text-2)', lineHeight: 1.45 } }, tx('page.filewizard.ai_metadata_desc', null, 'Turn on a field to let AI write it for every file at processing time (uses your AI allowance). Then expand any file below to set its values by hand — anything you type wins; fields you leave blank with their toggle on are filled automatically.'))
        ),
        h('div', { style: { display: 'flex', flexDirection: 'column', gap: 6 } },
          genRow('alt', tx('page.filewizard.gen_alt_title', null, 'Alt text (images)'), tx('page.filewizard.gen_alt_benefit', null, 'AI-written alt text for accessibility. Uses your AI allowance.'), hasImages),
          genRow('image_seo', tx('page.filewizard.gen_image_seo_title', null, 'Image SEO title + caption (images)'), tx('page.filewizard.gen_image_seo_benefit', null, 'SEO-friendly title and caption per image.'), hasImages),
          genRow('title', tx('page.filewizard.gen_title_title', null, 'Title (documents)'), tx('page.filewizard.gen_title_benefit', null, 'Readable document titles.'), hasDocs),
          genRow('description', tx('page.filewizard.gen_description_title', null, 'Description (documents)'), tx('page.filewizard.gen_description_benefit', null, 'Short summaries for each document.'), hasDocs),
          genRow('keywords', tx('page.filewizard.gen_keywords_title', null, 'Keywords (documents)'), tx('page.filewizard.gen_keywords_benefit', null, 'Topic keywords for findability.'), hasDocs)
        ),
        // ── Document date (documents) — free, no AI. Reads a date written in the filename/document
        //    and stamps the WordPress post date so the media library + listings sort by when the
        //    document is dated, not when you uploaded it. Honest fallback: when no date is clearly
        //    present we keep the upload date — we never invent one (Radical Transparency).
        hasDocs && h('div', { style: { display: 'flex', alignItems: 'flex-start', gap: 12, padding: '10px 12px', borderRadius: 'var(--r)', background: 'var(--surface-2)', border: '1px solid var(--border)' } },
          h('div', { style: { flex: 1, minWidth: 0 } },
            h('div', { style: { fontWeight: 'var(--fw-label)', fontSize: '.86rem', color: 'var(--text)' } }, tx('page.filewizard.set_doc_date_title', null, 'Set document date (documents)')),
            h('div', { style: { fontSize: '.76rem', color: 'var(--text-2)', marginTop: 2, lineHeight: 1.4 } }, tx('page.filewizard.set_doc_date_desc', null, 'Free — no AI. Reads a date written in the filename and stamps it as the file date so listings sort by when the document is dated. When no date is clearly present, we keep the upload date — never a guessed one.'))
          ),
          Toggle ? h(Toggle, { checked: !!meta.set_document_date, onChange: function (v) { setMeta({ set_document_date: v }); }, label: tx('page.filewizard.set_doc_date_toggle_label', null, 'Set document date') }) : h('input', { type: 'checkbox', checked: !!meta.set_document_date, onChange: function (e) { setMeta({ set_document_date: e.target.checked }); } })
        ),
        // ── Per-file overrides (Option 2) ──────────────────────────────────────
        files.length > 0 && h('div', { style: { display: 'flex', flexDirection: 'column', gap: 6, marginTop: 2, paddingTop: 10, borderTop: '1px solid var(--border)' } },
          h('span', { className: 'mono', style: lbl }, tx('page.filewizard.per_file_values', null, 'Per-file values (optional)')),
          h('div', { style: { fontSize: '.72rem', color: 'var(--dim)', lineHeight: 1.4 } }, tx('page.filewizard.per_file_desc', null, 'Expand a file to set or override its fields. Each field shows whether AI will fill it — your entry always wins, and applies only to that file.')),
          h('div', { style: { display: 'flex', flexDirection: 'column', gap: 4 } },
            files.map(function (f, i) {
              const fld = fieldsAll[i] || {};
              const edited = Object.keys(fld).length > 0;
              const open = openIdx === i;
              const fname = (f && (f.name || f.source_name)) || tx('page.filewizard.file_n', { n: i + 1 }, 'File {n}');
              return h('div', { key: i, style: { border: '1px solid var(--border)', borderRadius: 8, background: 'var(--surface)', overflow: 'hidden' } },
                h('button', { type: 'button', onClick: function () { setOpenIdx(open ? null : i); }, 'aria-expanded': open,
                  style: { width: '100%', display: 'flex', alignItems: 'center', gap: 8, padding: '8px 10px', background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text)', textAlign: 'left', font: 'inherit' } },
                  h(Icon, { name: 'chevron-right', size: 12, style: { transform: open ? 'rotate(90deg)' : 'none', transition: 'transform .15s', flex: '0 0 auto' } }),
                  h('span', { className: 'mono', style: { fontSize: '.74rem', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1 } }, fname),
                  edited && h('span', { className: 'tag beam', style: { fontSize: '.54rem', padding: '1px 6px' } }, tx('page.filewizard.edited', null, 'edited'))
                ),
                open && h('div', { style: { padding: '4px 12px 12px', display: 'flex', flexWrap: 'wrap', gap: 8 } },
                  FIELD_DEFS.map(function (def) {
                    const aiOn = def.gen != null && !!meta.gen[def.gen];
                    const hint = def.gen == null ? tx('page.filewizard.field_hint_manual', null, 'Manual') : (aiOn ? tx('page.filewizard.field_hint_ai_on', null, 'AI on — type to override') : tx('page.filewizard.field_hint_ai_off', null, 'AI off — type to set'));
                    const onChange = function (e) { setField(i, def.key, e.target.value); };
                    const inputStyle = Object.assign({}, selStyle, { maxWidth: '100%' });
                    return h('label', { key: def.key, style: { display: 'flex', flexDirection: 'column', gap: 3, minWidth: 0, flex: def.full ? '1 1 100%' : '1 1 200px' } },
                      h('span', { style: { display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', gap: 8 } },
                        h('span', { style: { fontSize: '.6rem', color: 'var(--muted)', textTransform: 'uppercase', letterSpacing: 1 } }, def.label),
                        h('span', { style: { fontSize: '.56rem', color: aiOn ? 'var(--beam)' : 'var(--dim)', whiteSpace: 'nowrap' } }, hint)
                      ),
                      def.multi
                        ? h('textarea', { value: fld[def.key] || '', rows: 2, placeholder: def.ph, onChange: onChange, style: Object.assign({}, inputStyle, { resize: 'vertical', fontFamily: 'inherit' }) })
                        : h('input', { value: fld[def.key] || '', placeholder: def.ph, onChange: onChange, style: inputStyle })
                    );
                  })
                )
              );
            })
          )
        )
      ),
      !(hasImages || hasDocs) && h('div', { style: { fontSize: '.78rem', color: 'var(--dim)' } }, tx('page.filewizard.add_files_first', null, 'Add files first to see the metadata options for their types.'))
    );
  }

  /* ════════════════════════ STEP: DESTINATION (single — Convert path) ═══
     Produces ctx.data.site (object) + ctx.data.route ({destination,...}). */
  function DestinationStep({ ctx }) {
    const api = ctx.__api || _api;
    const { state, reload } = useDestinations(api);
    const site = ctx.data.site || null;
    const route = ctx.data.route || { destination: 'media_library' };
    const pickSite = function (id) {
      const s = state.sites.find(function (x) { return String(x.id) === String(id); }) || null;
      ctx.patch({ site: s, route: { destination: 'media_library' } });
    };
    const elig = eligibleSites(state.sites);
    // Auto-select when exactly one healthy site exists (don't force a pick from a list of one).
    useEffect(function () {
      if (!state.loading && !site && elig.length === 1) pickSite(elig[0].id);
    }, [state.loading, state.sites]);
    if (state.loading) return h('div', { style: { color: 'var(--muted)', fontSize: '.84rem' } }, tx('page.filewizard.loading_sites', null, 'Loading connected sites\u2026'));
    if (!state.routing || !elig.length) {
      const hasStale = state.sites.length > 0;
      return NoticeCard ? h(NoticeCard, { accent: 'var(--beam)', icon: 'info', title: hasStale ? tx('page.filewizard.no_reachable_site_title', null, 'No reachable connected site') : tx('page.filewizard.no_routing_site_title', null, 'No connected site for routing'), body: hasStale ? tx('page.filewizard.no_reachable_site_body', null, 'A connected site is on file but isn\u2019t checking in right now, so it can\u2019t receive files. Open WPSiteBeam \u2192 Connection \u2192 Re-sync on the site, then Re-detect here.') : tx('page.filewizard.no_routing_site_body', null, 'Connect a WordPress site with the WPSiteBeam plugin (and a media-organizer plugin for folders/categories) to route files into place. Without one, files stay in the WPSiteBeam library for manual download.') })
        : h('div', { style: { fontSize: '.84rem', color: 'var(--text-2)' } }, hasStale ? tx('page.filewizard.site_unreachable_short', null, 'Your connected site isn\u2019t reachable right now \u2014 Re-sync it, then Re-detect.') : tx('page.filewizard.no_routing_site_short', null, 'No connected site for routing yet.'));
    }
    return h('div', { style: { display: 'flex', flexDirection: 'column', gap: 'var(--sp-4)' } },
      h('div', { style: { display: 'flex', alignItems: 'flex-end', gap: 10, flexWrap: 'wrap' } },
        h(SiteSelect, { sites: state.sites, siteId: site && site.id, onPick: pickSite }),
        h('button', { type: 'button', className: 'btn btn-ghost btn-sm', onClick: reload }, h(Icon, { name: 'redirect', size: 11 }), tx('page.filewizard.re_detect', null, 'Re-detect'))
      ),
      site && h(PlacementPicker, { site: site, value: route, onChange: function (v) { ctx.patch({ route: v }); } })
    );
  }

  /* ════════════════════════ STEP: BATCH BOARD (Organize per-batch) ═══
     decision #2: one batch = one destination, applied to every file in it.
     Produces ctx.data.batches = [{ id, name, fileIdx:[], site, route }].
     Files are referenced by their index in ctx.data.files; every file must
     land in exactly one batch (validate enforces full assignment). */
  function BatchBoard({ ctx }) {
    const api = ctx.__api || _api;
    const { state, reload } = useDestinations(api);
    const files = ctx.data.files || [];
    let batches = ctx.data.batches;
    if (!batches || !batches.length) {
      batches = [{ id: 'b1', name: tx('page.filewizard.batch_default_name', { n: 1 }, 'Batch {n}'), fileIdx: files.map(function (_, i) { return i; }), site: null, route: { destination: 'media_library' } }];
    }
    const setBatches = function (next) { ctx.patch({ batches: next }); };
    const assignedTo = function (i) { const b = batches.find(function (b) { return b.fileIdx.indexOf(i) >= 0; }); return b ? b.id : null; };
    const moveFile = function (i, toId) {
      const next = batches.map(function (b) { return Object.assign({}, b, { fileIdx: b.fileIdx.filter(function (x) { return x !== i; }) }); });
      const t = next.find(function (b) { return b.id === toId; }); if (t) t.fileIdx = t.fileIdx.concat(i).sort(function (a, b) { return a - b; });
      setBatches(next);
    };
    const addBatch = function () {
      const id = 'b' + (batches.length + 1 + Math.floor(Math.random() * 1000));
      setBatches(batches.concat({ id: id, name: tx('page.filewizard.batch_default_name', { n: batches.length + 1 }, 'Batch {n}'), fileIdx: [], site: null, route: { destination: 'media_library' } }));
    };
    const removeBatch = function (id) {
      if (batches.length <= 1) return;
      const victim = batches.find(function (b) { return b.id === id; });
      const rest = batches.filter(function (b) { return b.id !== id; });
      if (victim && victim.fileIdx.length) rest[0].fileIdx = rest[0].fileIdx.concat(victim.fileIdx).sort(function (a, b) { return a - b; });
      setBatches(rest);
    };
    const setBatch = function (id, partial) { setBatches(batches.map(function (b) { return b.id === id ? Object.assign({}, b, partial) : b; })); };

    // Auto-select the lone healthy site into every batch that has no site yet.
    const elig = eligibleSites(state.sites);
    useEffect(function () {
      if (state.loading || elig.length !== 1) return;
      if (!batches.some(function (b) { return !b.site; })) return;
      setBatches(batches.map(function (b) { return b.site ? b : Object.assign({}, b, { site: elig[0] }); }));
    }, [state.loading, state.sites]);

    return h('div', { style: { display: 'flex', flexDirection: 'column', gap: 'var(--sp-4)' } },
      NoticeCard && h(NoticeCard, { accent: 'var(--beam)', icon: 'info', title: tx('page.filewizard.batch_notice_title', null, 'Each batch goes to ONE place'), body: tx('page.filewizard.batch_notice_body', null, 'Every file in a batch lands in the same destination \u2014 the same site, folder, categories, and tags. To send files to different places, split them into separate batches. This boundary is intentional so a large multi-site import can\u2019t get cross-wired.') }),
      h('div', { style: { display: 'flex', justifyContent: 'space-between', alignItems: 'center' } },
        h('span', { className: 'mono', style: lbl }, batches.length === 1 ? tx('page.filewizard.batch_count_one', { n: batches.length }, '{n} batch') : tx('page.filewizard.batch_count_many', { n: batches.length }, '{n} batches')),
        h('button', { type: 'button', className: 'btn btn-ghost btn-sm', onClick: addBatch }, h(Icon, { name: 'plus', size: 12 }), tx('page.filewizard.add_batch', null, 'Add batch'))
      ),
      batches.map(function (b) {
        const site = b.site;
        return h('div', { key: b.id, style: { border: '1px solid var(--border)', borderRadius: 'var(--r-lg)', padding: 'var(--sp-4)', background: 'var(--surface)', display: 'flex', flexDirection: 'column', gap: 10 } },
          h('div', { style: { display: 'flex', alignItems: 'center', gap: 8 } },
            h('input', { value: b.name, onChange: function (e) { setBatch(b.id, { name: e.target.value }); }, style: Object.assign({}, selStyle, { maxWidth: 200, fontWeight: 'var(--fw-label)' }) }),
            h('span', { style: { fontSize: '.72rem', color: 'var(--dim)' } }, b.fileIdx.length === 1 ? tx('page.filewizard.file_count_one', { n: b.fileIdx.length }, '{n} file') : tx('page.filewizard.file_count_many', { n: b.fileIdx.length }, '{n} files')),
            h('div', { style: { flex: 1 } }),
            batches.length > 1 && h('button', { type: 'button', className: 'btn btn-ghost btn-sm', onClick: function () { removeBatch(b.id); }, 'aria-label': tx('page.filewizard.remove_x', { name: b.name }, 'Remove {name}') }, h(Icon, { name: 'trash', size: 12 }))
          ),
          /* destination for this batch */
          state.routing && (elig.length || (site && !siteIsEligible(site)))
            ? h('div', { style: { display: 'flex', flexDirection: 'column', gap: 8 } },
                h('div', { style: { display: 'flex', alignItems: 'flex-end', gap: 10, flexWrap: 'wrap' } },
                  h(SiteSelect, { sites: state.sites, siteId: site && site.id, onPick: function (id) { const s = state.sites.find(function (x) { return String(x.id) === String(id); }) || null; setBatch(b.id, { site: s, route: { destination: 'media_library' } }); } }),
                  h('button', { type: 'button', className: 'btn btn-ghost btn-sm', onClick: reload }, h(Icon, { name: 'redirect', size: 11 }), tx('page.filewizard.re_detect', null, 'Re-detect'))
                ),
                site && siteIsEligible(site) && h(PlacementPicker, { site: site, value: b.route, onChange: function (v) { setBatch(b.id, { route: v }); } })
              )
            : h('div', { style: { fontSize: '.76rem', color: 'var(--dim)' } }, tx('page.filewizard.no_routing_site_batch', null, 'No routing site available \u2014 this batch stays in the library for manual download.'))
        );
      }),
      /* file -> batch assignment */
      files.length > 0 && h('div', { style: { display: 'flex', flexDirection: 'column', gap: 6 } },
        h('div', { className: 'mono', style: lbl }, tx('page.filewizard.assign_files', null, 'Assign files to batches')),
        files.map(function (f, i) {
          return h('div', { key: i, style: { display: 'flex', alignItems: 'center', gap: 10, padding: '6px 10px', borderRadius: 'var(--r)', background: 'var(--surface-2)', border: '1px solid var(--border)' } },
            h(Icon, { name: isImage(f) ? 'image' : 'file-text', size: 13 }),
            h('span', { style: { flex: 1, minWidth: 0, fontSize: '.8rem', color: 'var(--text)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' } }, f.name),
            h('select', { value: assignedTo(i) || '', onChange: function (e) { moveFile(i, e.target.value); }, style: Object.assign({}, selStyle, { maxWidth: 160 }) },
              batches.map(function (b) { return h('option', { key: b.id, value: b.id }, b.name); }))
          );
        })
      )
    );
  }

  /* ════════════════════════ STEP: REVIEW ═══════════════════════════ */
  function ReviewStep({ ctx, extraSummary }) {
    const files = ctx.data.files || [];
    const meta = ctx.data.meta || {};
    const gen = meta.gen || {};
    const genList = Object.keys(gen).filter(function (k) { return gen[k]; });
    const batches = ctx.data.batches;
    const route = ctx.data.route;
    const site = ctx.data.site;
    const row = function (k, v) { return h('div', { key: k, style: { display: 'flex', justifyContent: 'space-between', gap: 16, padding: '7px 0', borderBottom: '1px solid var(--border)' } }, h('span', { style: { color: 'var(--muted)', fontSize: '.8rem' } }, k), h('span', { style: { color: 'var(--text)', fontSize: '.82rem', fontWeight: 'var(--fw-label)', textAlign: 'right' } }, v)); };
    const findTier1 = function (s, dest) {
      const t1 = (s && Array.isArray(s.tier1)) ? s.tier1 : [];
      return t1.find(function (o) { return o && o.destination === dest; }) || null;
    };
    const folderName = function (org, id) {
      const fs = (org && Array.isArray(org.folders)) ? org.folders : [];
      const f = fs.find(function (x) { return Number(x.id) === Number(id); });
      return f ? f.name : null;
    };
    const termNames = function (terms, ids) {
      if (!Array.isArray(terms) || !Array.isArray(ids)) return [];
      return ids.map(function (id) { const t = terms.find(function (x) { return Number(x.id) === Number(id); }); return t ? t.name : null; }).filter(Boolean);
    };
    const catNameOf = function (org, id) {
      const cs = (org && Array.isArray(org.categories)) ? org.categories : [];
      const c = cs.find(function (x) { return Number(x.id) === Number(id); });
      return c ? c.name : null;
    };
    // Category labels for a DLP route: existing + new top-level + new children (with parent).
    const dlpCatLabels = function (r, org) {
      const out = termNames(org && org.categories, r.category_ids).slice();
      (r.new_categories || []).forEach(function (n) { out.push(tx('page.filewizard.chip_new', { name: n }, '{name} (new)')); });
      (r.new_child_categories || []).forEach(function (c) { const p = catNameOf(org, c.parent); out.push(p ? tx('page.filewizard.new_child_under_label', { name: c.name, parent: p }, '{name} (new child under {parent})') : tx('page.filewizard.new_child_label', { name: c.name }, '{name} (new child)')); });
      return out;
    };
    const placementDetail = function (r, s) {
      const org = findTier1(s, r.destination);
      if (r.destination === 'dlp') {
        const cats = dlpCatLabels(r, org);
        const tags = termNames(org && org.tags, r.tag_ids).concat((r.new_tags || []).map(function (n) { return tx('page.filewizard.chip_new', { name: n }, '{name} (new)'); }));
        const bits = [];
        if (cats.length) bits.push(tx('page.filewizard.categories_list', { list: cats.join(', ') }, 'Categories: {list}'));
        if (tags.length) bits.push(tx('page.filewizard.tags_list', { list: tags.join(', ') }, 'Tags: {list}'));
        return bits.length ? bits.join(' \u00b7 ') : tx('page.filewizard.new_document_per_file', null, 'New document per file');
      }
      if (r.create && r.create.name) {
        const pName = r.create.parent ? folderName(org, r.create.parent) : null;
        return pName ? tx('page.filewizard.folder_detail_new_under', { name: r.create.name, parent: pName }, 'Folder: {name} (new, under {parent})') : tx('page.filewizard.folder_detail_new', { name: r.create.name }, 'Folder: {name} (new)');
      }
      const fn = folderName(org, r.folder_id);
      return fn ? tx('page.filewizard.folder_detail', { name: fn }, 'Folder: {name}') : tx('page.filewizard.organizer_root', null, 'Organizer root');
    };
    const destText = function (r, s) {
      if (!r || r.destination === 'media_library') return s ? tx('page.filewizard.dest_site_media_library', { site: s.name || s.url }, '{site} \u2014 Media Library') : tx('page.filewizard.media_library', null, 'Media Library');
      const orgLabel = r.destination === 'dlp' ? tx('page.filewizard.document_library', null, 'Document Library') : r.destination;
      const base = [s ? (s.name || s.url) : '', orgLabel].filter(Boolean).join(' \u2014 ');
      const detail = placementDetail(r, s);
      return detail ? base + ' \u2014 ' + detail : base;
    };
    // Structured detail rows for the single-destination summary: Destination on its
    // own line, then Folder / Categories / Tags broken out so they read clearly.
    const destRows = function (r, s) {
      if (!r || r.destination === 'media_library') {
        return [ row(tx('page.filewizard.destination', null, 'Destination'), s ? tx('page.filewizard.dest_site_media_library', { site: s.name || s.url }, '{site} \u2014 Media Library') : tx('page.filewizard.media_library', null, 'Media Library')) ];
      }
      const org = findTier1(s, r.destination);
      const orgLabel = r.destination === 'dlp' ? tx('page.filewizard.document_library', null, 'Document Library') : r.destination;
      const out = [ row(tx('page.filewizard.destination', null, 'Destination'), [s ? (s.name || s.url) : '', orgLabel].filter(Boolean).join(' \u2014 ')) ];
      if (r.destination === 'dlp') {
        const cats = dlpCatLabels(r, org);
        const tags = termNames(org && org.tags, r.tag_ids).concat((r.new_tags || []).map(function (n) { return tx('page.filewizard.chip_new', { name: n }, '{name} (new)'); }));
        out.push(row(tx('page.filewizard.categories', null, 'Categories'), cats.length ? cats.join(', ') : tx('page.filewizard.none', null, 'None')));
        out.push(row(tx('page.filewizard.tags', null, 'Tags'), tags.length ? tags.join(', ') : tx('page.filewizard.none', null, 'None')));
      } else if (r.create && r.create.name) {
        const pName = r.create.parent ? folderName(org, r.create.parent) : null;
        out.push(row(tx('page.filewizard.folder', null, 'Folder'), pName ? tx('page.filewizard.folder_value_new_under', { name: r.create.name, parent: pName }, '{name} (new, under {parent})') : tx('page.filewizard.folder_value_new', { name: r.create.name }, '{name} (new)')));
      } else {
        const fn = folderName(org, r.folder_id);
        out.push(row(tx('page.filewizard.folder', null, 'Folder'), fn || tx('page.filewizard.organizer_root', null, 'Organizer root')));
      }
      return out;
    };
    // Generate-toggle clarity: body + excerpt are AI-generated only when the Description
    // toggle is on. Categories/Tags are picker-driven (never AI), so call that out so an
    // off-state doesn't read as a bug at the confirm step.
    const routesList = (batches && batches.length) ? batches.map(function (b) { return b.route; }) : [route];
    const anyDlp = routesList.some(function (r) { return r && r.destination === 'dlp'; });
    const descOff = !gen.description;
    const genNote = (anyDlp && descOff) ? h('div', { style: { fontSize: '.72rem', color: 'var(--warn)', lineHeight: 1.5, background: 'rgba(0,0,0,.15)', border: '1px solid var(--border)', borderRadius: 8, padding: '8px 10px' } },
      h('strong', {}, tx('page.filewizard.gennote_off', null, 'Description generation is off.')), tx('page.filewizard.gennote_1', null, ' Routed documents will have '), h('strong', {}, tx('page.filewizard.gennote_no_ai_body', null, 'no AI body')), tx('page.filewizard.gennote_2', null, ', and the Excerpt falls back to the file title. Categories and Tags come from your selections above — they’re not AI-generated. Turn on '), h('strong', {}, tx('page.filewizard.gennote_description', null, 'Description')), tx('page.filewizard.gennote_3', null, ' in the Metadata step to auto-write the body + excerpt.')
    ) : null;
    return h('div', { style: { display: 'flex', flexDirection: 'column', gap: 'var(--sp-4)' } },
      h('div', { style: { display: 'flex', flexDirection: 'column' } },
        row(tx('page.filewizard.files', null, 'Files'), String(files.length)),
        meta.rename && meta.rename.enabled ? row(tx('page.filewizard.rename_pattern_label', null, 'Rename pattern'), meta.rename.pattern || tx('page.filewizard.set', null, '(set)')) : null,
        genList.length ? row(tx('page.filewizard.generate', null, 'Generate'), genList.join(', ')) : row(tx('page.filewizard.generate', null, 'Generate'), tx('page.filewizard.generate_off', null, 'Off (no AI metadata)')),
        batches && batches.length > 1
          ? row(tx('page.filewizard.destinations', null, 'Destinations'), tx('page.filewizard.batch_count_many', { n: batches.length }, '{n} batches'))
          : destRows(
              (batches && batches.length === 1) ? batches[0].route : route,
              (batches && batches.length === 1) ? batches[0].site : site
            )
      ),
      genNote,
      batches && batches.length > 1 && h('div', { style: { display: 'flex', flexDirection: 'column', gap: 6 } },
        h('div', { className: 'mono', style: lbl }, tx('page.filewizard.per_batch_destinations', null, 'Per-batch destinations')),
        batches.map(function (b) {
          return h('div', { key: b.id, style: { display: 'flex', justifyContent: 'space-between', gap: 12, fontSize: '.8rem', padding: '5px 0', borderBottom: '1px dashed var(--border)' } },
            h('span', { style: { color: 'var(--text-2)' } }, tx('page.filewizard.batch_name_count', { name: b.name, n: b.fileIdx.length }, '{name} ({n})')),
            h('span', { style: { color: 'var(--text)', textAlign: 'right' } }, destText(b.route, b.site)));
        })
      ),
      extraSummary ? extraSummary(ctx) : null
    );
  }

  const exports = {
    SourceStep: SourceStep, MetadataStep: MetadataStep, DestinationStep: DestinationStep,
    BatchBoard: BatchBoard, ReviewStep: ReviewStep, PlacementPicker: PlacementPicker, SiteSelect: SiteSelect,
    useDestinations: useDestinations, uploadAndStage: uploadAndStage, putToSignedUrlXHR: putToSignedUrlXHR,
    foldersWithDepth: foldersWithDepth, termsTree: termsTree, fmtBytes: fmtBytes, makeApi: makeApi, isImage: isImage, isDocLike: isDocLike,
  };
  window.WPSB = window.WPSB || {};
  window.WPSB.FileWizard = Object.assign(window.WPSB.FileWizard || {}, exports);
  console.log('[WPSB FileWizard] Shared steps loaded (source/metadata/destination/batch/review) — eligible-site picker + auto-select + per-file metadata override (50/50 layout + AI-state hints) + set-document-date toggle + category hierarchy + most-used tag quick-pick.');
})();
