/* ══════════════════════════════════════════════════════════════════
   FileMapClusters.jsx
   ══════════════════════════════════════════════════════════════════
   Content Clusters view for the File Map. Groups scanned file URLs
   by detected pattern (Commission Minutes, Ordinances, Blog posts,
   Staff pages, etc.) and gives each cluster a one-line bulk rewrite
   with live preview + Apply.

   Architecture:
     • CLUSTER_PRESETS — dictionary of industry presets, each is an
       ordered list of clusters. First-match wins; anything that
       matches nothing lands in the "Unclustered" bucket.
     • detectCluster(url, preset)     → {clusterId, vars}
     • renderTemplate(template, vars) → string — {year}, {slug}…
     • <ClustersView/> — the UI: industry dropdown + cluster list,
       each with a bulk rewrite input, preview, Apply button.

   Exposed as window.FileMapClusters = { detectCluster, CLUSTER_PRESETS,
   ClustersView, renderTemplate }.
   ══════════════════════════════════════════════════════════════════ */

(function() {
  const { useMemo, useState } = React;

  /* ── Utility: template render ──────────────────────────────────
     Replaces {token} with vars[token]. Unknown tokens pass through
     unchanged so the user sees where the gap is. Supports optional
     filters on tokens: {year:pad4}, {month:pad2}, {slug:kebab}. */
  function renderTemplate(template, vars) {
    if (!template) return '';
    return template.replace(/\{([a-z_]+)(?::([a-z0-9]+))?\}/gi, (match, token, filter) => {
      let v = vars[token];
      if (v === undefined || v === null || v === '') return match; // leave gap visible
      v = String(v);
      if (filter === 'pad2') v = v.padStart(2, '0');
      if (filter === 'pad4') v = v.padStart(4, '0');
      if (filter === 'kebab') {
        v = v.toLowerCase()
             .normalize('NFD').replace(/[\u0300-\u036f]/g, '')
             .replace(/[^a-z0-9]+/g, '-')
             .replace(/-+/g, '-')
             .replace(/^-|-$/g, '');
      }
      if (filter === 'lower') v = v.toLowerCase();
      return v;
    });
  }

  /* ── Cluster presets ───────────────────────────────────────────
     Each cluster has:
       id:        stable key
       label:     display name
       icon:      Icon name
       hint:      short description of what it catches
       test(url): returns {vars} if match, null otherwise
       defaultTemplate: starting rewrite template
     First match wins — order matters (specific before generic). */

  // Helper — normalize month names to 2-digit string
  const MONTHS = {
    jan:'01', feb:'02', mar:'03', apr:'04', may:'05', jun:'06',
    jul:'07', aug:'08', sep:'09', oct:'10', nov:'11', dec:'12',
    january:'01', february:'02', march:'03', april:'04', june:'06',
    july:'07', august:'08', september:'09', october:'10', november:'11', december:'12',
  };
  const monthNum = (s) => MONTHS[(s || '').toLowerCase().slice(0, 3)] || MONTHS[(s || '').toLowerCase()] || null;

  // Strip the filename from a path and return dir/name/ext
  function pathParts(url) {
    const q = url.split('?')[0];
    const parts = q.split('/').filter(Boolean);
    const name = parts.pop() || '';
    const dot = name.lastIndexOf('.');
    const base = dot > 0 ? name.slice(0, dot) : name;
    const ext = dot > 0 ? name.slice(dot + 1).toLowerCase() : '';
    return { dir: '/' + parts.join('/'), full: name, base, ext };
  }

  const CLUSTER_PRESETS = {
    /* ── MUNICIPAL / CIVIC ─────────────────────────────────────── */
    municipal: {
      label: 'Municipal / Civic',
      icon: 'building',
      hint: 'Commission minutes, ordinances, departments, permits, notices',
      clusters: [
        {
          id: 'commission-minutes',
          label: 'Commission / Council Minutes',
          icon: 'docs',
          hint: 'Meeting minutes PDFs with dates',
          defaultTemplate: '/meetings/{committee:kebab}/{year}-{month:pad2}-{day:pad2}-minutes.{ext}',
          test(url) {
            const { full, base, ext } = pathParts(url);
            if (!/minutes/i.test(full)) return null;
            // Pattern A: "Commission Minutes 1-15-25.PDF"
            let m = base.match(/(\w[\w\s]*?)\s*minutes\s*(\d{1,2})[-\/.](\d{1,2})[-\/.](\d{2,4})/i);
            if (m) {
              const yr = m[4].length === 2 ? '20' + m[4] : m[4];
              return { vars: { committee: (m[1] || 'commission').trim() || 'commission',
                month: m[2], day: m[3], year: yr, ext } };
            }
            // Pattern B: "Feb 12 2023 Meeting Minutes"
            m = base.match(/([A-Za-z]{3,9})\s+(\d{1,2})[,\s]+(\d{4}).*minutes/i);
            if (m) {
              const mo = monthNum(m[1]);
              if (mo) return { vars: { committee: 'commission', month: mo, day: m[2], year: m[3], ext } };
            }
            // Pattern C: "2023-01-15 Commission Minutes"
            m = base.match(/(\d{4})-(\d{1,2})-(\d{1,2})[\s_-]+(\w[\w\s]*?)\s*minutes/i);
            if (m) {
              return { vars: { committee: (m[4] || 'commission').trim(), year: m[1], month: m[2], day: m[3], ext } };
            }
            // Generic minutes file with no date — still cluster
            return { vars: { committee: 'commission', year: '', month: '', day: '', ext, filename: base } };
          },
        },
        {
          id: 'agendas',
          label: 'Agendas',
          icon: 'docs',
          hint: 'Agenda PDFs',
          defaultTemplate: '/meetings/{committee:kebab}/{year}-{month:pad2}-{day:pad2}-agenda.{ext}',
          test(url) {
            const { full, base, ext } = pathParts(url);
            if (!/agenda/i.test(full)) return null;
            let m = base.match(/(\w[\w\s]*?)\s*agenda\s*(\d{1,2})[-\/.](\d{1,2})[-\/.](\d{2,4})/i);
            if (m) {
              const yr = m[4].length === 2 ? '20' + m[4] : m[4];
              return { vars: { committee: (m[1] || 'commission').trim(), month: m[2], day: m[3], year: yr, ext } };
            }
            m = base.match(/([A-Za-z]{3,9})\s+(\d{1,2})[,\s]+(\d{4}).*agenda/i);
            if (m) {
              const mo = monthNum(m[1]);
              if (mo) return { vars: { committee: 'commission', month: mo, day: m[2], year: m[3], ext } };
            }
            return { vars: { committee: 'commission', ext, filename: base } };
          },
        },
        {
          id: 'ordinances',
          label: 'Ordinances / Resolutions',
          icon: 'flag',
          hint: 'Numbered ordinances & resolutions',
          defaultTemplate: '/ordinances/{year}/{number}.{ext}',
          test(url) {
            const { full, base, ext } = pathParts(url);
            if (!/ordinance|resolution/i.test(full)) return null;
            const m = base.match(/(ordinance|resolution)[_\s-]*(\d{4})[_\s-]*(\d{1,4})/i);
            if (m) return { vars: { year: m[2], number: m[3], ext } };
            const m2 = base.match(/(ord|res)[_\s-]*(\d{1,4})[_\s-]*(\d{2,4})/i);
            if (m2) return { vars: { year: m2[3].length === 2 ? '20' + m2[3] : m2[3], number: m2[2], ext } };
            return { vars: { year: '', number: '', ext, filename: base } };
          },
        },
        {
          id: 'notices',
          label: 'Public Notices',
          icon: 'warn',
          hint: 'Legal / public notices',
          defaultTemplate: '/notices/{year}/{slug:kebab}.{ext}',
          test(url) {
            const { full, base, ext } = pathParts(url);
            if (!/notice|legal-notice|public-notice/i.test(full)) return null;
            const m = base.match(/(\d{4})/);
            return { vars: { year: m ? m[1] : '', slug: base, ext } };
          },
        },
        {
          id: 'permits-forms',
          label: 'Permits & Forms',
          icon: 'docs',
          hint: 'Building permits, forms',
          defaultTemplate: '/permits/{slug:kebab}.{ext}',
          test(url) {
            const { full, base, ext } = pathParts(url);
            if (!/permit|form|application/i.test(full)) return null;
            return { vars: { slug: base, ext } };
          },
        },
        {
          id: 'budget-finance',
          label: 'Budget / Financial',
          icon: 'chart',
          hint: 'Budget, CAFR, audit reports',
          defaultTemplate: '/finance/{year}-{slug:kebab}.{ext}',
          test(url) {
            const { full, base, ext } = pathParts(url);
            if (!/budget|audit|cafr|finance/i.test(full)) return null;
            const m = base.match(/(\d{4})/);
            return { vars: { year: m ? m[1] : '', slug: base.replace(/\d{4}/, '').trim(), ext } };
          },
        },
        {
          id: 'rfps-bids',
          label: 'RFPs / Bids',
          icon: 'docs',
          hint: 'Procurement, purchasing',
          defaultTemplate: '/procurement/{type:lower}/{id}.{ext}',
          test(url) {
            const { full, base, ext } = pathParts(url);
            if (!/rfp|rfq|bid|procurement/i.test(full)) return null;
            const m = base.match(/(rfp|rfq|bid)[_\s-]*(\w+)/i);
            if (m) return { vars: { type: m[1], id: m[2], ext } };
            return { vars: { type: 'rfp', id: base, ext } };
          },
        },
        {
          id: 'departments',
          label: 'Department Pages',
          icon: 'building',
          hint: 'Department URLs',
          defaultTemplate: '/departments/{slug:kebab}',
          test(url) {
            if (!/\/(dept|department|publicworks|pw|parks|police|fire|water)/i.test(url)) return null;
            const { full, base } = pathParts(url);
            return { vars: { slug: base || full } };
          },
        },
        {
          id: 'boards',
          label: 'Boards & Commissions',
          icon: 'team',
          hint: 'Board / commission pages',
          defaultTemplate: '/boards/{slug:kebab}',
          test(url) {
            if (!/(commission|board|council|committee)(?!.*minutes)(?!.*agenda)/i.test(url)) return null;
            const { base } = pathParts(url);
            return { vars: { slug: base } };
          },
        },
        {
          id: 'staff',
          label: 'Staff Directory',
          icon: 'team',
          hint: 'Employee / staff pages',
          defaultTemplate: '/directory/{slug:kebab}',
          test(url) {
            if (!/\/(staff|employee|personnel|directory)\//i.test(url)) return null;
            const { base } = pathParts(url);
            return { vars: { slug: base } };
          },
        },
      ],
    },

    /* ── BLOG / CONTENT ────────────────────────────────────────── */
    blog: {
      label: 'Blog / Content',
      icon: 'docs',
      hint: 'WordPress posts, categories, tags, authors, legacy URLs',
      clusters: [
        {
          id: 'legacy-p',
          label: 'Legacy ?p= URLs',
          icon: 'warn',
          hint: 'WP default permalink (/?p=123)',
          defaultTemplate: '/blog/{id}',
          test(url) {
            const m = url.match(/[?&]p=(\d+)/);
            return m ? { vars: { id: m[1] } } : null;
          },
        },
        {
          id: 'date-slug',
          label: 'Date-based posts',
          icon: 'clock',
          hint: '/YYYY/MM/DD/slug/ or /YYYY/MM/slug/',
          defaultTemplate: '/blog/{slug:kebab}',
          test(url) {
            const m = url.match(/\/(\d{4})\/(\d{1,2})(?:\/(\d{1,2}))?\/([^/?#]+)/);
            return m ? { vars: { year: m[1], month: m[2], day: m[3] || '', slug: m[4] } } : null;
          },
        },
        {
          id: 'category',
          label: 'Categories',
          icon: 'flag',
          hint: '/category/{slug}/',
          defaultTemplate: '/topics/{slug:kebab}',
          test(url) {
            const m = url.match(/\/category\/([^/?#]+)/i);
            return m ? { vars: { slug: m[1] } } : null;
          },
        },
        {
          id: 'tag',
          label: 'Tags',
          icon: 'flag',
          hint: '/tag/{slug}/',
          defaultTemplate: '/topics/{slug:kebab}',
          test(url) {
            const m = url.match(/\/tag\/([^/?#]+)/i);
            return m ? { vars: { slug: m[1] } } : null;
          },
        },
        {
          id: 'author',
          label: 'Authors',
          icon: 'team',
          hint: '/author/{username}/',
          defaultTemplate: '/team/{slug:kebab}',
          test(url) {
            const m = url.match(/\/author\/([^/?#]+)/i);
            return m ? { vars: { slug: m[1] } } : null;
          },
        },
        {
          id: 'wp-uploads',
          label: 'WP Uploads',
          icon: 'archive',
          hint: '/wp-content/uploads/YYYY/MM/*',
          defaultTemplate: '/uploads/{year}/{filename:kebab}.{ext}',
          test(url) {
            const m = url.match(/\/wp-content\/uploads\/(\d{4})\/(\d{2})\/(.+)$/);
            if (!m) return null;
            const { base, ext } = pathParts('/' + m[3]);
            return { vars: { year: m[1], month: m[2], filename: base, ext } };
          },
        },
      ],
    },

    /* ── CUSTOM ────────────────────────────────────────────────── */
    custom: {
      label: 'Custom',
      icon: 'sliders',
      hint: 'No presets — promote URLs from Unclustered to define your own',
      clusters: [],
    },
  };

  /* ── Detect which cluster a URL belongs to ─────────────────── */
  function detectCluster(url, presetId) {
    const preset = CLUSTER_PRESETS[presetId];
    if (!preset) return { clusterId: 'unclustered', vars: {} };
    for (const c of preset.clusters) {
      const r = c.test(url);
      if (r) return { clusterId: c.id, vars: r.vars || {} };
    }
    return { clusterId: 'unclustered', vars: {} };
  }

  /* ══════════════════════════════════════════════════════════════
     ClustersView — UI
     ══════════════════════════════════════════════════════════════
     Props:
       items:     [{id, oldUrl, newUrl, kind, size, status, flags, page}]
       presetId:  'municipal' | 'blog' | 'custom'
       onPresetChange(id)
       onApplyCluster(clusterId, rewrites:{id: newUrl}[])
       onOpenRow(id)     // switch to Split view
  */
  function ClustersView({ items, presetId, onPresetChange, onApplyCluster, onOpenRow }) {
    // Group items by detected cluster
    const grouped = useMemo(() => {
      const byCluster = {};
      for (const it of items) {
        const { clusterId, vars } = detectCluster(it.oldUrl, presetId);
        if (!byCluster[clusterId]) byCluster[clusterId] = [];
        byCluster[clusterId].push({ ...it, _vars: vars });
      }
      return byCluster;
    }, [items, presetId]);

    const preset = CLUSTER_PRESETS[presetId] || CLUSTER_PRESETS.municipal;
    const unclustered = grouped.unclustered || [];

    return (
      <div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
        {/* Industry preset selector */}
        <div style={{ display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap', padding: '8px 12px', background: 'var(--surface-3)', border: '1px solid var(--border)', borderRadius: 8 }}>
          <span className="mono" style={{ fontSize: '.58rem', letterSpacing: 1, textTransform: 'uppercase', color: 'var(--dim)' }}>
            Industry Preset
          </span>
          <div style={{ display: 'flex', gap: 4 }}>
            {Object.entries(CLUSTER_PRESETS).map(([id, p]) => (
              <button key={id}
                className={'btn btn-ghost btn-sm' + (presetId === id ? ' active' : '')}
                style={presetId === id ? {
                  background: 'var(--beam-dim)', color: 'var(--beam)',
                  borderColor: 'var(--beam-dim)',
                } : null}
                onClick={() => onPresetChange(id)}>
                <Icon name={p.icon} size={12}/>
                <span style={{ marginLeft: 6 }}>{p.label}</span>
              </button>
            ))}
          </div>
          <div style={{ flex: 1 }}/>
          <span style={{ fontSize: '.7rem', color: 'var(--dim)' }}>
            {preset.hint}
          </span>
        </div>

        {/* Cluster cards */}
        {preset.clusters.map(cluster => {
          const members = grouped[cluster.id] || [];
          if (members.length === 0) return null;
          return (
            <ClusterCard key={cluster.id}
              cluster={cluster}
              members={members}
              onApply={(rewrites) => onApplyCluster(cluster.id, rewrites)}
              onOpenRow={onOpenRow}
            />
          );
        })}

        {/* Empty state when the preset matches nothing */}
        {preset.clusters.every(c => !grouped[c.id] || grouped[c.id].length === 0) && (
          <div style={{ padding: 20, textAlign: 'center', background: 'var(--surface-3)', borderRadius: 8, border: '1px dashed var(--border-2)' }}>
            <div style={{ fontSize: '.82rem', color: 'var(--text)', marginBottom: 4 }}>No clusters detected</div>
            <div style={{ fontSize: '.7rem', color: 'var(--dim)' }}>
              Try a different preset, or check the Unclustered bucket below.
            </div>
          </div>
        )}

        {/* Unclustered bucket */}
        {unclustered.length > 0 && (
          <UnclusteredCard
            members={unclustered}
            onOpenRow={onOpenRow}
            onPromote={(sampleUrl) => window.wpsbToast(`Promote "${sampleUrl}" to a new cluster (coming soon)`, 'info')}
          />
        )}
      </div>
    );
  }

  /* ── Individual cluster card with bulk rewrite ─────────────── */
  function ClusterCard({ cluster, members, onApply, onOpenRow }) {
    const [template, setTemplate] = useState(cluster.defaultTemplate);
    const [expanded, setExpanded] = useState(false);

    // Compute live preview — first 3 members get rendered
    const previews = useMemo(() => {
      return members.slice(0, 3).map(m => ({
        id: m.id,
        from: m.oldUrl,
        to: renderTemplate(template, { ...m._vars, ...pathPartsVars(m.oldUrl) }),
      }));
    }, [members, template]);

    // Count how many members have complete variable resolution (no {gaps})
    const resolvable = useMemo(() => {
      return members.filter(m => {
        const out = renderTemplate(template, { ...m._vars, ...pathPartsVars(m.oldUrl) });
        return !/\{[a-z_]+/i.test(out);
      }).length;
    }, [members, template]);

    const flagsCount = members.reduce((acc, m) => acc + ((m.flags || []).length > 0 ? 1 : 0), 0);

    const handleApply = () => {
      const rewrites = members
        .map(m => {
          const to = renderTemplate(template, { ...m._vars, ...pathPartsVars(m.oldUrl) });
          if (/\{[a-z_]+/i.test(to)) return null; // skip incomplete
          return { id: m.id, newUrl: to };
        })
        .filter(Boolean);
      if (rewrites.length === 0) {
        window.wpsbToast('No complete rewrites — template still has unresolved tokens', 'warn');
        return;
      }
      onApply(rewrites);
    };

    return (
      <div className="card">
        <div className="card-head" style={{ flexWrap: 'wrap', gap: 10 }}>
          <div style={{ display: 'flex', alignItems: 'center', gap: 10, flex: 1, minWidth: 0 }}>
            <div style={{ width: 32, height: 32, borderRadius: 6, background: 'var(--surface-3)', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
              <Icon name={cluster.icon} size={16}/>
            </div>
            <div style={{ flex: 1, minWidth: 0 }}>
              <h3 className="card-title" style={{ margin: 0, fontSize: '.95rem' }}>
                {cluster.label} <span style={{ color: 'var(--dim)', fontWeight: 400, fontSize: '.78rem', fontFamily: 'var(--font-mono)' }}>· {members.length}</span>
              </h3>
              <div style={{ fontSize: '.68rem', color: 'var(--dim)', marginTop: 2 }}>{cluster.hint}</div>
            </div>
            {flagsCount > 0 && (
              <span className="tag warn" style={{ fontSize: '.58rem' }}>
                {flagsCount} flagged
              </span>
            )}
          </div>
          <button className="btn btn-ghost btn-sm" onClick={() => setExpanded(v => !v)}>
            <Icon name={expanded ? 'chevron-down' : 'chevron-right'} size={11}/>
            {expanded ? 'Hide' : 'Show'} files
          </button>
        </div>
        <div className="card-body" style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
          {/* Rewrite rule row */}
          <div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
            <span className="mono" style={{ fontSize: '.62rem', color: 'var(--dim)', letterSpacing: .5 }}>Rewrite →</span>
            <input
              type="text"
              value={template}
              onChange={e => setTemplate(e.target.value)}
              className="mono"
              style={{
                flex: 1, minWidth: 280,
                padding: '7px 10px',
                background: 'var(--surface-3)',
                border: '1px solid var(--border)',
                borderRadius: 6,
                color: 'var(--text)',
                fontSize: '.75rem',
              }}
              aria-label={`Rewrite template for ${cluster.label}`}
            />
            <button className="btn btn-primary btn-sm" onClick={handleApply}>
              <Icon name="check" size={11}/>
              Apply {resolvable}/{members.length}
            </button>
          </div>

          {/* Token palette — click to insert */}
          <div style={{ display: 'flex', flexWrap: 'wrap', gap: 4 }}>
            <span style={{ fontSize: '.6rem', color: 'var(--dim)', alignSelf: 'center', marginRight: 4 }}>Tokens:</span>
            {['{year}', '{month:pad2}', '{day:pad2}', '{committee:kebab}', '{slug:kebab}', '{number}', '{filename}', '{ext}'].map(tok => (
              <button key={tok} type="button"
                className="mono"
                style={{
                  padding: '2px 7px', fontSize: '.6rem',
                  background: 'var(--beam-dim)', color: 'var(--beam)',
                  border: '1px solid var(--beam-dim)',
                  borderRadius: 4, cursor: 'pointer',
                }}
                onClick={() => setTemplate(t => t + tok)}
                title={`Append ${tok} to template`}>
                {tok}
              </button>
            ))}
          </div>

          {/* Live preview — first 3 */}
          <div style={{ background: 'var(--surface-3)', borderRadius: 6, padding: 10, border: '1px solid var(--border)' }}>
            <div className="mono" style={{ fontSize: '.58rem', color: 'var(--dim)', letterSpacing: 1, textTransform: 'uppercase', marginBottom: 6 }}>
              Preview (first {previews.length} of {members.length})
            </div>
            <div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
              {previews.map(p => (
                <div key={p.id} className="mono" style={{ fontSize: '.7rem', display: 'grid', gridTemplateColumns: '1fr auto 1fr', gap: 8, alignItems: 'center' }}>
                  <div style={{ color: 'var(--dim)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }} title={p.from}>{p.from}</div>
                  <span style={{ color: 'var(--dim)' }}>→</span>
                  <div style={{
                    color: /\{[a-z_]+/i.test(p.to) ? 'var(--warn)' : 'var(--green)',
                    overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
                  }} title={p.to}>{p.to || <em style={{ color: 'var(--dim)' }}>—</em>}</div>
                </div>
              ))}
            </div>
          </div>

          {/* Expanded: full member list */}
          {expanded && (
            <div style={{ borderTop: '1px solid var(--border)', paddingTop: 10 }}>
              <table className="table">
                <thead>
                  <tr>
                    <th>File</th>
                    <th style={{ width: 80 }}>Size</th>
                    <th style={{ width: 140 }}>Detected vars</th>
                    <th style={{ width: 60 }}></th>
                  </tr>
                </thead>
                <tbody>
                  {members.map(m => (
                    <tr key={m.id} style={{ cursor: 'pointer' }} onClick={() => onOpenRow(m.id)}>
                      <td className="mono" style={{ fontSize: '.7rem' }}>
                        {m.oldUrl.split('/').pop()}
                        {m.page && <div style={{ fontSize: '.58rem', color: 'var(--dim)' }}>on {m.page}</div>}
                      </td>
                      <td className="mono" style={{ fontSize: '.68rem', color: 'var(--dim)' }}>{m.size}</td>
                      <td className="mono" style={{ fontSize: '.6rem', color: 'var(--dim)' }}>
                        {Object.entries(m._vars || {}).filter(([k, v]) => v).slice(0, 3).map(([k, v]) => (
                          <span key={k} style={{ marginRight: 6 }}>
                            {k}=<span style={{ color: 'var(--beam)' }}>{String(v)}</span>
                          </span>
                        ))}
                      </td>
                      <td style={{ textAlign: 'right' }}>
                        <button className="btn btn-ghost btn-sm" onClick={(e) => { e.stopPropagation(); onOpenRow(m.id); }}>
                          <Icon name="chevron-right" size={11}/>
                        </button>
                      </td>
                    </tr>
                  ))}
                </tbody>
              </table>
            </div>
          )}
        </div>
      </div>
    );
  }

  /* ── Unclustered bucket ────────────────────────────────────── */
  function UnclusteredCard({ members, onOpenRow, onPromote }) {
    const [expanded, setExpanded] = useState(false);
    return (
      <div className="card" style={{ borderColor: 'var(--orange-dim)' }}>
        <div className="card-head" style={{ flexWrap: 'wrap', gap: 10 }}>
          <div style={{ display: 'flex', alignItems: 'center', gap: 10, flex: 1 }}>
            <div style={{ width: 32, height: 32, borderRadius: 6, background: 'var(--surface-3)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
              <Icon name="warn" size={16} style={{ color: 'var(--warn)' }}/>
            </div>
            <div style={{ flex: 1 }}>
              <h3 className="card-title" style={{ margin: 0, fontSize: '.95rem', color: 'var(--warn)' }}>
                Unclustered <span style={{ color: 'var(--dim)', fontWeight: 400, fontSize: '.78rem', fontFamily: 'var(--font-mono)' }}>· {members.length}</span>
              </h3>
              <div style={{ fontSize: '.68rem', color: 'var(--dim)', marginTop: 2 }}>
                Files that didn't match any preset pattern — handle individually or promote to a new cluster.
              </div>
            </div>
          </div>
          <button className="btn btn-ghost btn-sm" onClick={() => setExpanded(v => !v)}>
            <Icon name={expanded ? 'chevron-down' : 'chevron-right'} size={11}/>
            {expanded ? 'Hide' : 'Show'} files
          </button>
        </div>
        {expanded && (
          <div className="card-body">
            <table className="table">
              <thead>
                <tr>
                  <th>File</th>
                  <th style={{ width: 80 }}>Size</th>
                  <th style={{ width: 180, textAlign: 'right' }}></th>
                </tr>
              </thead>
              <tbody>
                {members.map(m => (
                  <tr key={m.id}>
                    <td className="mono" style={{ fontSize: '.7rem' }}>
                      {m.oldUrl.split('/').pop()}
                      {m.page && <div style={{ fontSize: '.58rem', color: 'var(--dim)' }}>on {m.page}</div>}
                    </td>
                    <td className="mono" style={{ fontSize: '.68rem', color: 'var(--dim)' }}>{m.size}</td>
                    <td style={{ textAlign: 'right' }}>
                      <button className="btn btn-ghost btn-sm" onClick={() => onPromote(m.oldUrl)}>
                        <Icon name="plus" size={11}/> New cluster
                      </button>
                      <button className="btn btn-ghost btn-sm" onClick={() => onOpenRow(m.id)}>
                        <Icon name="chevron-right" size={11}/>
                      </button>
                    </td>
                  </tr>
                ))}
              </tbody>
            </table>
          </div>
        )}
      </div>
    );
  }

  /* Helper to expose pathParts fields as template vars (ext, filename) */
  function pathPartsVars(url) {
    const p = pathParts(url);
    return { ext: p.ext, filename: p.base };
  }

  // Export for use in Redirects.jsx
  window.FileMapClusters = {
    CLUSTER_PRESETS,
    detectCluster,
    renderTemplate,
    ClustersView,
  };
})();
