    let session = null;
    let editorCategories = [];
    let activeIndex = 0;
    let fileVisibility = {};
    let uploadWarnings = [];
    let sortState = { key: null, direction: 'asc' };
    let licenseState = null;
    let columnWidths = {};
    const DEFAULT_COLUMN_WIDTHS = { post_date: 132 };
    const NOTE_MAX_LENGTH = 300;
    const NOTE_AUTOSAVE_DEBOUNCE_MS = 450;
    const noteSaveTimers = {};

    const SORT_LABELS = {
      source_file: 'Source File',
      post_date: 'Date',
      description: 'Description',
      amount: 'Amount',
      balance: 'Balance',
      direction: 'Direction',
      row_confidence: 'Confidence',
      review: 'Flag',
      category: 'Category',
      note: 'Note',
    };

    function setStatus(message) {
      document.getElementById('statusLine').textContent = message || '';
    }

    function setUploadProgress(visible, label, percent, indeterminate) {
      const panel = document.getElementById('uploadProgressPanel');
      if (!panel) {
        return;
      }

      const labelEl = document.getElementById('uploadProgressLabel');
      const percentEl = document.getElementById('uploadProgressPercent');
      const fillEl = document.getElementById('uploadProgressFill');
      if (!labelEl || !percentEl || !fillEl) {
        return;
      }

      if (!visible) {
        panel.hidden = true;
        panel.classList.remove('indeterminate');
        fillEl.style.width = '0%';
        percentEl.textContent = '0%';
        return;
      }

      panel.hidden = false;
      labelEl.textContent = label || 'Working...';
      if (indeterminate) {
        panel.classList.add('indeterminate');
        percentEl.textContent = '...';
        fillEl.style.width = '45%';
        return;
      }

      panel.classList.remove('indeterminate');
      const safePercent = Math.max(0, Math.min(100, Math.round(Number(percent || 0))));
      percentEl.textContent = `${safePercent}%`;
      fillEl.style.width = `${safePercent}%`;
    }

    async function parseApiError(response, fallbackMessage) {
      try {
        const payload = await response.json();
        if (payload && payload.detail) {
          return String(payload.detail);
        }
      } catch (error) {
        // ignore JSON parse errors and fall back to text
      }
      try {
        const text = await response.text();
        if (text && text.trim()) {
          return text;
        }
      } catch (error) {
        // ignore text read errors
      }
      return fallbackMessage;
    }

    function applyLicenseUi() {
      const isPro = String(licenseState?.edition || 'free').toLowerCase() === 'pro';

      const holder = document.getElementById('licenseHolder');
      if (holder) {
        holder.textContent = `Licensed to: ${licenseState?.licensed_to || 'Community Edition'}`;
      }

      const licenseBadge = document.getElementById('licenseBadge');
      if (licenseBadge) {
        licenseBadge.textContent = isPro ? 'Pro' : 'Unlicensed';
        licenseBadge.classList.remove('community', 'pro');
        licenseBadge.classList.add(isPro ? 'pro' : 'community');
        licenseBadge.title = isPro
          ? `Pro license active for ${licenseState?.licensed_to || 'licensed user'}`
          : 'No license active';
      }

      const buyLicenseLink = document.getElementById('buyLicenseLink');
      if (buyLicenseLink) {
        buyLicenseLink.hidden = isPro;
      }

      const canExportXlsx = !!licenseState?.features?.xlsx_export;
      const canEditCategories = !!licenseState?.features?.category_editor;

      const exportPrimaryButton = document.getElementById('exportPrimaryButton');
      if (exportPrimaryButton) {
        exportPrimaryButton.disabled = false;
        exportPrimaryButton.classList.toggle('pro-locked-hover', !canExportXlsx);
        exportPrimaryButton.title = !canExportXlsx
          ? 'Buy a license to enable this feature.'
          : 'Default export: full workbook (XLSX)';
      }

      document.querySelectorAll('[data-export-format="xlsx"]').forEach((btn) => {
        btn.disabled = !canExportXlsx;
        btn.title = !canExportXlsx
          ? 'Buy a license to enable this feature.'
          : '';
      });

      const addCategoryButton = document.getElementById('addCategoryButton');
      if (addCategoryButton) {
        addCategoryButton.disabled = false;
        addCategoryButton.classList.toggle('pro-locked-hover', !canEditCategories);
        addCategoryButton.title = !canEditCategories
          ? 'Buy a license to enable this feature.'
          : '';
      }

      const newCategoryInput = document.getElementById('newCategoryInput');
      if (newCategoryInput) {
        newCategoryInput.disabled = false;
        newCategoryInput.title = '';
      }

      const categoryEditorButton = document.getElementById('openCategoryEditorButton');
      if (categoryEditorButton) {
        categoryEditorButton.disabled = false;
        categoryEditorButton.classList.toggle('pro-locked-hover', !canEditCategories);
        categoryEditorButton.title = !canEditCategories
          ? 'Buy a license to enable this feature.'
          : '';
      }
    }

    async function refreshLicenseState() {
      const response = await fetch('/api/license');
      if (!response.ok) {
        throw new Error(await parseApiError(response, 'Failed to load license information.'));
      }
      licenseState = await response.json();
      applyLicenseUi();
      return licenseState;
    }

    function setUploadWarnings(warnings) {
      uploadWarnings = Array.isArray(warnings) ? warnings.map((item) => String(item || '')).filter(Boolean) : [];
      const container = document.getElementById('uploadNotice');
      if (!container) {
        return;
      }
      if (!uploadWarnings.length) {
        container.style.display = 'none';
        container.innerHTML = '';
        return;
      }

      container.style.display = 'block';
      container.innerHTML = `<strong>Upload notices</strong><ul>${uploadWarnings.map((item) => `<li>${escapeHtmlAttr(item)}</li>`).join('')}</ul>`;
    }

    function badgeClass(file) {
      if (file.error_message) return 'badge danger';
      if (!file.matched) return 'badge warn';
      return 'badge ok';
    }

    function syncFileVisibility(files) {
      const nextVisibility = {};
      (files || []).forEach((file) => {
        const key = String(file.source_file || '');
        // If user has already set visibility, keep it; otherwise default based on match status
        if (fileVisibility[key] !== undefined) {
          nextVisibility[key] = fileVisibility[key];
        } else {
          nextVisibility[key] = file.matched !== false;
        }
      });
      fileVisibility = nextVisibility;
    }

    function isFileVisible(sourceFile) {
      return fileVisibility[String(sourceFile || '')] !== false;
    }

    function visibleFiles() {
      return (session?.files || []).filter((file) => isFileVisible(file.source_file));
    }

    function normalizeCategory(value) {
      return String(value || '').trim().replace(/\s+/g, ' ');
    }

    function escapeHtmlAttr(value) {
      return String(value || '')
        .replace(/&/g, '&amp;')
        .replace(/"/g, '&quot;')
        .replace(/</g, '&lt;')
        .replace(/>/g, '&gt;');
    }

    function renderReviewFlag(row) {
      if (row.review_flag === 'yes') {
        const reason = escapeHtmlAttr(row.review_reason || 'flagged');
        return `<span class="review-flag-chip flagged" title="${reason}">!</span>`;
      }
      return '<span class="review-flag-chip clear">-</span>';
    }

    const MONEY_FORMATTER = new Intl.NumberFormat('en-US', {
      style: 'currency',
      currency: 'USD',
      minimumFractionDigits: 2,
      maximumFractionDigits: 2,
    });

    const PERCENT_FORMATTER = new Intl.NumberFormat('en-US', {
      style: 'percent',
      minimumFractionDigits: 0,
      maximumFractionDigits: 0,
    });

    function formatMoney(value) {
      if (value === null || value === undefined || value === '') {
        return '';
      }
      const parsed = Number(value);
      if (!Number.isFinite(parsed)) {
        return escapeHtmlAttr(value);
      }
      return MONEY_FORMATTER.format(parsed);
    }

    function renderConfidence(value) {
      if (value === null || value === undefined || value === '') {
        return '';
      }
      const parsed = Number(value);
      if (!Number.isFinite(parsed)) {
        return escapeHtmlAttr(value);
      }
      const level = parsed < 0.8 ? 'low' : parsed < 0.9 ? 'medium' : 'high';
      return `<span class="confidence-value ${level}" title="raw confidence ${parsed.toFixed(2)}">${PERCENT_FORMATTER.format(parsed)}</span>`;
    }

    function categoryOptions(selectedValue = '') {
      const selected = normalizeCategory(selectedValue);
      const known = new Set((session?.categories || []).map((item) => normalizeCategory(item)).filter(Boolean));
      if (selected) {
        known.add(selected);
      }

      return [
        `<option value="" ${selected ? '' : 'selected'}>Uncategorized</option>`,
        ...Array.from(known).sort((left, right) => left.localeCompare(right)).map((category) => (
          `<option value="${category}" ${category === selected ? 'selected' : ''}>${category}</option>`
        )),
      ].join('');
    }

    function refreshCategoryFilterOptions() {
      const select = document.getElementById('categoryFilter');
      if (!select) {
        return;
      }

      const currentValue = select.value || 'all';
      const options = [
        '<option value="all">All categories</option>',
        '<option value="uncategorized">Uncategorized only</option>',
        ...(session?.categories || []).map((category) => `<option value="${category}">${category}</option>`),
      ];
      select.innerHTML = options.join('');
      select.value = options.some((option) => option.includes(`value="${currentValue}"`)) ? currentValue : 'all';
    }

    function sortIndicator(key) {
      if (sortState.key !== key) {
        return ' ';
      }
      return sortState.direction === 'asc' ? '↑' : '↓';
    }

    function sortValue(row, key) {
      if (key === 'source_file') return String(row._source_file || '').toLowerCase();
      if (key === 'post_date') return String(row.post_date || '');
      if (key === 'description') return String(row.description || '').toLowerCase();
      if (key === 'amount') return Number(row.amount || 0);
      if (key === 'balance') return row.balance == null ? Number.NEGATIVE_INFINITY : Number(row.balance);
      if (key === 'direction') return String(row.direction || '').toLowerCase();
      if (key === 'row_confidence') return Number(row.row_confidence || 0);
      if (key === 'review') return String(row.review_reason || row.review_flag || '').toLowerCase();
      if (key === 'category') return normalizeCategory(row.category).toLowerCase();
      if (key === 'note') return String(row.note || '').toLowerCase();
      return '';
    }

    function compareRows(left, right) {
      if (!sortState.key) {
        return 0;
      }

      const leftValue = sortValue(left, sortState.key);
      const rightValue = sortValue(right, sortState.key);
      let result = 0;

      if (typeof leftValue === 'number' && typeof rightValue === 'number') {
        result = leftValue - rightValue;
      } else {
        result = String(leftValue).localeCompare(String(rightValue));
      }

      if (result === 0) {
        result = String(left._source_file || '').localeCompare(String(right._source_file || ''));
      }
      if (result === 0) {
        result = String(left.post_date || '').localeCompare(String(right.post_date || ''));
      }
      if (result === 0) {
        result = String(left.description || '').localeCompare(String(right.description || ''));
      }

      return sortState.direction === 'asc' ? result : -result;
    }

    function sortRows(rows) {
      if (!sortState.key) {
        return rows;
      }
      return [...rows].sort(compareRows);
    }

    function updateAggBar(rows) {
      const bar = document.getElementById('aggBar');
      const el = document.getElementById('aggAmount');
      if (!rows || !rows.length) {
        bar.style.display = 'none';
        el.style.display = 'none';
        el.innerHTML = '';
        return;
      }
      bar.style.display = 'flex';
      const func = document.getElementById('aggFunc').value;

      function normalizedAmount(row) {
        const raw = parseFloat(row.amount);
        if (isNaN(raw)) return null;
        const direction = String(row.direction || '').toLowerCase();
        if (direction === 'debit') return -Math.abs(raw);
        if (direction === 'credit') return Math.abs(raw);
        const accountType = String(row.account_type || '').toLowerCase();
        if (accountType === 'credit_card') return -raw;
        return raw;
      }

      const amounts = rows.map(normalizedAmount).filter((v) => v !== null && !isNaN(v));
      let val = null;
      if (amounts.length) {
        if (func === 'count') val = rows.length;
        else if (func === 'sum') val = amounts.reduce((a, b) => a + b, 0);
        else if (func === 'abssum') val = amounts.reduce((a, b) => a + Math.abs(b), 0);
        else if (func === 'avg') val = amounts.reduce((a, b) => a + b, 0) / amounts.length;
        else if (func === 'min') val = Math.min(...amounts);
        else if (func === 'max') val = Math.max(...amounts);
      }

      if (val === null) { el.style.display = 'none'; return; }
      const isCount = func === 'count';
      const fmtVal = isCount ? String(val) : formatMoney(val);
      const cls = (!isCount && val < 0) ? 'negative' : (!isCount && val > 0) ? 'positive' : '';
      el.style.display = 'flex';
      el.innerHTML = `<span class="agg-label">Amount</span><span class="agg-value ${cls}">${fmtVal}</span>`;
    }

    function columnWidthStyle(key) {
      const width = columnWidths[key] || DEFAULT_COLUMN_WIDTHS[key];
      return width ? ` style="width: ${width}px; min-width: ${width}px;"` : '';
    }

    function renderColGroup(keys) {
      return `<colgroup>${keys.map((key) => `<col data-column-key="${key}"${columnWidthStyle(key)}>`).join('')}</colgroup>`;
    }

    function sortableHeader(key, label) {
      const numericKeys = new Set(['amount', 'balance', 'row_confidence']);
      const isNumeric = numericKeys.has(key);
      return `
        <th class="sortable resizable${isNumeric ? ' numeric' : ''}" data-sort-key="${key}"${columnWidthStyle(key)}>
          <div class="header-cell${isNumeric ? ' numeric' : ''}">
            <span class="header-label">${label}<span class="sort-indicator">${sortIndicator(key)}</span></span>
            <span class="column-resize-handle" data-resize-key="${key}" title="Drag to resize column"></span>
          </div>
        </th>
      `;
    }

    function bindSortHeaders() {
      document.querySelectorAll('[data-sort-key]').forEach((header) => {
        header.addEventListener('click', () => {
          const key = header.dataset.sortKey;
          if (sortState.key === key) {
            sortState.direction = sortState.direction === 'asc' ? 'desc' : 'asc';
          } else {
            sortState = { key, direction: 'asc' };
          }
          renderRows();
        });
      });
    }

    function bindResizeHandles() {
      document.querySelectorAll('[data-resize-key]').forEach((handle) => {
        handle.addEventListener('mousedown', (event) => {
          event.preventDefault();
          event.stopPropagation();

          const key = handle.dataset.resizeKey;
          const header = handle.closest('th');
          const startWidth = header.getBoundingClientRect().width;
          const startX = event.clientX;
          handle.classList.add('active');
          document.body.style.cursor = 'col-resize';
          document.body.style.userSelect = 'none';

          const onMouseMove = (moveEvent) => {
            const nextWidth = Math.max(96, Math.round(startWidth + (moveEvent.clientX - startX)));
            columnWidths[key] = nextWidth;
            document.querySelectorAll(`[data-column-key="${key}"]`).forEach((col) => {
              col.style.width = `${nextWidth}px`;
              col.style.minWidth = `${nextWidth}px`;
            });
            document.querySelectorAll(`th[data-sort-key="${key}"]`).forEach((th) => {
              th.style.width = `${nextWidth}px`;
              th.style.minWidth = `${nextWidth}px`;
            });
          };

          const onMouseUp = () => {
            handle.classList.remove('active');
            document.body.style.cursor = '';
            document.body.style.userSelect = '';
            document.removeEventListener('mousemove', onMouseMove);
            document.removeEventListener('mouseup', onMouseUp);
          };

          document.addEventListener('mousemove', onMouseMove);
          document.addEventListener('mouseup', onMouseUp);
        });
      });
    }

    function currentExportQuery(format, mode = 'full') {
      const exportViewOnly = mode === 'view';
      const params = new URLSearchParams({
        format,
        export_mode: mode,
        scope: 'all',
        query: exportViewOnly ? (document.getElementById('searchInput').value || '') : '',
        row_filter: 'all',
        start_date: exportViewOnly ? (document.getElementById('startDate').value || '') : '',
        end_date: exportViewOnly ? (document.getElementById('endDate').value || '') : '',
        category_filter: exportViewOnly ? (document.getElementById('categoryFilter').value || 'all') : 'all',
      });
      visibleFiles().forEach((entry) => {
        params.append('visible_source', entry.source_file);
      });
      return params.toString();
    }

    function currentViewIsFiltered() {
      const totalFiles = (session?.files || []).length;
      const visibleCount = visibleFiles().length;
      return (
        visibleCount !== totalFiles
        || (document.getElementById('searchInput').value || '').trim() !== ''
        || (document.getElementById('startDate').value || '') !== ''
        || (document.getElementById('endDate').value || '') !== ''
        || (document.getElementById('categoryFilter').value || 'all') !== 'all'
      );
    }

    async function addCategory() {
      if (!licenseState?.features?.category_editor) {
        setStatus('Adding categories is a Pro feature. Open License to activate Pro.');
        return;
      }

      const input = document.getElementById('newCategoryInput');
      const name = normalizeCategory(input.value);
      if (!name) {
        setStatus('Enter a category name first.');
        return;
      }

      const response = await fetch('/api/categories', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ name }),
      });
      if (!response.ok) {
        setStatus(await response.text() || 'Failed to add category.');
        return;
      }

      session = await response.json();
      input.value = '';
      refreshCategoryFilterOptions();
      renderSummary(session.summary);
      renderFileList(session.files);
      renderActiveFile();
      setStatus(`Added category: ${name}`);
    }

    async function updateRowCategory(rowId, category) {
      const response = await fetch('/api/rows/update', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ row_id: rowId, category }),
      });
      if (!response.ok) {
        setStatus(await response.text() || 'Failed to update row category.');
        return;
      }

      session = await response.json();
      refreshCategoryFilterOptions();
      renderSummary(session.summary);
      renderFileList(session.files);
      renderActiveFile();
      setStatus(category ? `Assigned category: ${category}` : 'Cleared row category.');
    }

    async function updateRowNote(rowId, note, options = {}) {
      const rerender = options.rerender !== false;
      const quiet = options.quiet === true;
      const normalizedNote = String(note || '').trim();
      if (normalizedNote.length > NOTE_MAX_LENGTH) {
        setStatus(`Note must be ${NOTE_MAX_LENGTH} characters or fewer.`);
        return false;
      }

      const response = await fetch('/api/rows/update', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ row_id: rowId, note: normalizedNote }),
      });
      if (!response.ok) {
        setStatus(await response.text() || 'Failed to update row note.');
        return false;
      }

      session = await response.json();
      if (rerender) {
        renderSummary(session.summary);
        renderFileList(session.files);
        renderActiveFile();
      }
      if (!quiet) {
        setStatus(normalizedNote ? 'Saved row note.' : 'Cleared row note.');
      }
      return true;
    }

    async function applyCategoryToMatches(rowId, category) {
      const normalizedCategory = normalizeCategory(category);
      if (!normalizedCategory) {
        setStatus('Pick a category first, then apply it to matching descriptions.');
        return;
      }

      const response = await fetch('/api/rows/apply-category-rule', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ row_id: rowId, category: normalizedCategory }),
      });
      if (!response.ok) {
        setStatus(await response.text() || 'Failed to apply category rule.');
        return;
      }

      const payload = await response.json();
      session = payload.session;
      refreshCategoryFilterOptions();
      renderSummary(session.summary);
      renderFileList(session.files);
      renderActiveFile();
      setStatus(`Applied ${normalizedCategory} to ${payload.applied_count} matching row(s).`);
    }

    function moveRowToUncategorized(rowId) {
      updateRowCategory(rowId, '');
    }

    function bindRowNoteEditors() {
      document.querySelectorAll('.row-note-input').forEach((input) => {
        const maxLen = NOTE_MAX_LENGTH;
        const rowId = input.dataset.rowId;
        input.dataset.lastSaved = String(input.value || '').trim();

        const clamp = () => {
          if (input.value.length > maxLen) {
            input.value = input.value.slice(0, maxLen);
          }
        };

        const saveNote = (immediate = false) => {
          clamp();
          const nextValue = String(input.value || '').trim();
          const previousValue = String(input.dataset.lastSaved || '');
          if (nextValue === previousValue || !rowId) {
            return;
          }

          const runSave = async () => {
            const saved = await updateRowNote(rowId, nextValue, { rerender: false, quiet: true });
            if (saved) {
              input.dataset.lastSaved = nextValue;
            }
          };

          if (noteSaveTimers[rowId]) {
            clearTimeout(noteSaveTimers[rowId]);
            delete noteSaveTimers[rowId];
          }

          if (immediate) {
            runSave();
          } else {
            noteSaveTimers[rowId] = setTimeout(() => {
              delete noteSaveTimers[rowId];
              runSave();
            }, NOTE_AUTOSAVE_DEBOUNCE_MS);
          }
        };

        input.addEventListener('input', () => saveNote(false));
        input.addEventListener('blur', () => {
          saveNote(true);
        });
        input.addEventListener('keydown', (event) => {
          if (event.key === 'Enter') {
            event.preventDefault();
            input.blur();
          }
        });
      });
    }

    function renderEditorOptions() {
      const select = document.getElementById('editorCategorySelect');
      if (!select) return;
      const options = [
        '<option value="">Select category...</option>',
        ...editorCategories.map((item) => `<option value="${item.name}">${item.name}</option>`),
      ];
      select.innerHTML = options.join('');
    }

    function populateEditorFromSelection(name) {
      const record = editorCategories.find((item) => item.name === name);
      const nameInput = document.getElementById('editorCategoryName');
      const termsInput = document.getElementById('editorTerms');
      const priorityInput = document.getElementById('editorPriority');
      const deleteBtn = document.getElementById('editorDeleteButton');
      const hintDiv = document.getElementById('editorBuiltinHint');
      const hintPre = document.getElementById('editorBuiltinPatterns');
      if (!record) {
        nameInput.value = name || '';
        nameInput.readOnly = false;
        termsInput.value = '';
        termsInput.placeholder = 'payroll\npaycheck\nach direct deposit\nmy company name pch';
        priorityInput.value = '65';
        if (deleteBtn) deleteBtn.disabled = true;
        if (hintDiv) hintDiv.style.display = 'none';
        return;
      }
      nameInput.value = record.name || '';
      nameInput.readOnly = true;
      const userPatterns = record.user_patterns || [];
      if (userPatterns.length) {
        // Regex-based user rule (saved via "Save as Rule") — show patterns read-only.
        termsInput.value = userPatterns.join('\n');
        termsInput.readOnly = true;
        termsInput.placeholder = '';
      } else {
        termsInput.value = (record.terms || []).join('\n');
        termsInput.readOnly = false;
        termsInput.placeholder = record.is_user_rule
          ? 'one term per line'
          : 'Add your own terms here and Save to override the built-in rule.';
      }
      priorityInput.value = String(record.priority || 65);
      if (deleteBtn) deleteBtn.disabled = !record.is_user_rule;
      const hints = record.hint_patterns || [];
      if (hintDiv && hintPre) {
        if (hints.length) {
          hintPre.textContent = hints.join('\n');
          hintDiv.style.display = '';
        } else {
          hintDiv.style.display = 'none';
        }
      }
    }

    async function loadCategoryEditor() {
      const response = await fetch('/api/categories/editor');
      if (!response.ok) {
        setStatus(await parseApiError(response, 'Failed to load category editor data.'));
        return;
      }
      const payload = await response.json();
      editorCategories = payload.categories || [];
      renderEditorOptions();
    }

    async function saveCategoryEditorTerms() {
      const name = normalizeCategory(document.getElementById('editorCategoryName').value);
      if (!name) {
        setStatus('Enter a category name in the editor.');
        return;
      }
      const terms = document.getElementById('editorTerms').value
        .split('\n')
        .map((term) => term.trim())
        .filter(Boolean);
      const priority = parseInt(document.getElementById('editorPriority').value, 10) || 65;

      const response = await fetch('/api/categories/editor/save', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ name, terms, priority }),
      });
      if (!response.ok) {
        setStatus(await parseApiError(response, 'Failed to save category terms.'));
        return;
      }

      const payload = await response.json();
      session = payload.session;
      editorCategories = payload.categories || [];
      refreshCategoryFilterOptions();
      renderSummary(session.summary);
      renderFileList(session.files);
      renderActiveFile();
      renderEditorOptions();
      document.getElementById('editorCategorySelect').value = name;
      populateEditorFromSelection(name);
      setStatus(`Saved category terms for ${name}. Updated ${payload.updated_count || 0} row(s).`);
    }

    async function deleteCategoryEditorOverride() {
      const name = normalizeCategory(document.getElementById('editorCategoryName').value);
      if (!name) {
        setStatus('Choose a category name to delete override.');
        return;
      }

      const response = await fetch('/api/categories/editor/delete', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ name }),
      });
      if (!response.ok) {
        setStatus(await parseApiError(response, 'Failed to delete user override.'));
        return;
      }

      const payload = await response.json();
      session = payload.session;
      editorCategories = payload.categories || [];
      refreshCategoryFilterOptions();
      renderSummary(session.summary);
      renderFileList(session.files);
      renderActiveFile();
      renderEditorOptions();
      populateEditorFromSelection('');
      setStatus(`Deleted user override for ${name}. Updated ${payload.updated_count || 0} row(s).`);
    }

    function bindCategoryEditors() {
      document.querySelectorAll('.row-category-editor').forEach((editor) => {
        const select = editor.querySelector('.row-category');
        const saveRuleButton = editor.querySelector('.row-category-save-rule');
        const uncategorizeButton = editor.querySelector('.row-category-uncategorize');
        if (!select || !saveRuleButton || !uncategorizeButton) {
          return;
        }

        const syncButtonState = () => {
          const hasCategory = !!normalizeCategory(select.value);
          saveRuleButton.disabled = !hasCategory;
          uncategorizeButton.disabled = !hasCategory;
        };

        syncButtonState();
        select.addEventListener('change', () => {
          syncButtonState();
          updateRowCategory(select.dataset.rowId, select.value);
        });

        saveRuleButton.addEventListener('click', () => {
          openSaveRuleModal(
            select.dataset.rowId,
            saveRuleButton.dataset.description || '',
            normalizeCategory(select.value),
          );
        });

        uncategorizeButton.addEventListener('click', () => {
          select.value = '';
          syncButtonState();
          moveRowToUncategorized(select.dataset.rowId);
        });
      });
    }

    function renderSummary(summary, view = null) {
      const total = summary.total_files || 0;
      const matched = summary.matched_files || 0;
      const unmatched = summary.unmatched_files || 0;
      const visibleFilesCount = view && typeof view.visibleFilesCount === 'number'
        ? view.visibleFilesCount
        : visibleFiles().length;
      const rows = view && typeof view.rows === 'number'
        ? view.rows
        : visibleFiles().reduce((acc, file) => acc + Number(file.row_count || 0), 0);
      const flagged = summary.rows_flagged_for_review || 0;
      const matchedTip = 'A file is “matched” when LedgerSprout found a parser in the registry that recognises its format. Unmatched files were loaded but no parser could identify them.';
      const flagPart = flagged ? ` &middot; <strong>${flagged}</strong> flagged` : '';
      const unmatchedPart = unmatched ? ` &middot; <span style="color:var(--danger)"><strong>${unmatched}</strong> unmatched</span>` : '';
      document.getElementById('summary').innerHTML =
        `<strong>${total}</strong> file${total === 1 ? '' : 's'} ` +
        `(<strong>${visibleFilesCount}</strong> visible &middot; <span class="matched-help" title="${matchedTip}"><strong>${matched}</strong> matched</span>${unmatchedPart}) ` +
        `&middot; <strong>${rows}</strong> row${rows === 1 ? '' : 's'}${flagPart}`;
    }

    function renderFileList(files) {
      const container = document.getElementById('fileList');
      syncFileVisibility(files);
      if (!files.length) {
        fileVisibility = {};
        container.innerHTML = '<div class="empty">No PDFs loaded yet.</div>';
        return;
      }
      container.innerHTML = files.map((file, index) => `
        <div class="file-item ${index === activeIndex ? 'active' : ''}" data-index="${index}">
          <div class="file-item-main" data-index="${index}">
            <div class="file-item-head">
              <span class="name">${file.source_file}</span>
              <div class="file-item-controls">
                <label class="file-item-visibility" title="Show in data table">
                  <input class="file-item-visible-toggle" data-visible-index="${index}" type="checkbox" ${isFileVisible(file.source_file) ? 'checked' : ''}>
                  show
                </label>
                <button class="file-item-unload" data-unload-index="${index}" type="button" title="Unload file from current session" aria-label="Unload file">Unload</button>
              </div>
            </div>
            <div class="badge-row">
              <span class="${badgeClass(file)}">${file.matched ? 'matched' : 'needs review'}</span>
              <span class="badge">${file.row_count} rows</span>
              <button class="file-item-info" data-info-index="${index}" title="File diagnostics" type="button" aria-label="File diagnostics">&#x2139;</button>
            </div>
          </div>
        </div>
      `).join('');

      container.querySelectorAll('.file-item-main').forEach((main) => {
        main.addEventListener('click', () => {
          activeIndex = Number(main.dataset.index);
          renderActiveFile();
          renderFileList(files);
        });
      });

      container.querySelectorAll('.file-item-info').forEach((btn) => {
        btn.addEventListener('click', (e) => {
          e.stopPropagation();
          activeIndex = Number(btn.dataset.infoIndex);
          renderActiveFile();
          renderFileList(files);
          document.getElementById('diagModalBackdrop').classList.add('open');
        });
      });

      container.querySelectorAll('.file-item-visible-toggle').forEach((toggle) => {
        toggle.addEventListener('change', (e) => {
          e.stopPropagation();
          const index = Number(toggle.dataset.visibleIndex);
          const file = files[index];
          if (!file) {
            return;
          }
          fileVisibility[String(file.source_file || '')] = toggle.checked;
          renderRows();
        });
      });

      container.querySelectorAll('.file-item-visibility').forEach((label) => {
        label.addEventListener('click', (e) => {
          e.stopPropagation();
        });
      });

      container.querySelectorAll('.file-item-unload').forEach((button) => {
        button.addEventListener('click', (e) => {
          e.stopPropagation();
          unloadFile(Number(button.dataset.unloadIndex));
        });
      });
    }

    function renderActiveFile() {
      const file = session.files[activeIndex];
      if (!file) {
        document.getElementById('fileDetails').innerHTML = '<p class="empty">No files loaded. Start with `ledgersprout review path/to/pdfs` or drop PDFs and exported CSV/XLSX files into the window.</p>';
        document.getElementById('rowsInfo').textContent = 'Showing 0 row(s) from 0 visible file(s).';
        document.getElementById('rowsRegion').innerHTML = '<p class="empty">No rows to display.</p>';
        updateAggBar([]);
        return;
      }

      const warnings = file.warning_messages || [];
      const candidates = file.parser_candidates || [];
      const selectedScore = file.selected_score == null ? 'n/a' : file.selected_score.toFixed(2);
      const selectedThreshold = file.selected_threshold == null ? 'n/a' : file.selected_threshold.toFixed(2);
      document.getElementById('fileDetails').innerHTML = `
        <div class="meta-list">
          <div class="meta-row"><span>Matched</span><strong>${file.matched ? 'Yes' : 'No'}</strong></div>
          <div class="meta-row"><span>Selected parser</span><strong>${file.selected_parser_id || 'none'}</strong></div>
          <div class="meta-row"><span>Score</span><strong>${selectedScore}</strong></div>
          <div class="meta-row"><span>Threshold</span><strong>${selectedThreshold}</strong></div>
          <div class="meta-row"><span>Rows exported</span><strong>${file.row_count}</strong></div>
        </div>
        ${file.error_message ? `<div class="warning-list"><div class="warning"><strong>Error</strong><div>${file.error_message}</div></div></div>` : ''}
        <div class="warning-list">
          ${warnings.length ? warnings.map((warning) => `<div class="warning">${warning}</div>`).join('') : '<div class="warning">No warnings for this file.</div>'}
        </div>
        <div class="candidate-list">
          ${candidates.map((candidate) => `
            <div class="candidate">
              <strong>${candidate.parser_id}</strong><br>
              score ${candidate.score.toFixed(2)} / threshold ${candidate.threshold.toFixed(2)}${candidate.selected ? ' / selected' : ''}
            </div>
          `).join('') || '<div class="candidate">No parser candidates recorded.</div>'}
        </div>
      `;
      renderRows();
    }

    function renderRows() {
      const tableView = document.getElementById('tableView').checked ? 'grouped' : 'flat';
      const query = document.getElementById('searchInput').value.toLowerCase().trim();
      const startDate = document.getElementById('startDate').value;
      const endDate = document.getElementById('endDate').value;
      const categoryFilter = document.getElementById('categoryFilter').value;

      const scopedRows = visibleFiles().flatMap((entry) =>
        (entry.rows || []).map((row) => ({ ...row, _source_file: entry.source_file }))
      );

      const rows = scopedRows.filter((row) => {
        const noteText = String(row.note || '').toLowerCase();
        const matchesSearch = !query || row.description.toLowerCase().includes(query) || row.post_date.includes(query) || noteText.includes(query);
        const rowCategory = normalizeCategory(row.category);
        const matchesStartDate = !startDate || row.post_date >= startDate;
        const matchesEndDate = !endDate || row.post_date <= endDate;
        const matchesCategory = categoryFilter === 'all'
          || (categoryFilter === 'uncategorized' ? !rowCategory : rowCategory === categoryFilter);
        return matchesSearch && matchesStartDate && matchesEndDate && matchesCategory;
      });
      renderSummary(session.summary, { visibleFilesCount: visibleFiles().length, rows: rows.length });
      const sortedRows = sortRows(rows);

      const fileCount = new Set(rows.map((row) => row._source_file)).size;
      const visibleCount = visibleFiles().length;
      const totalFiles = (session.files || []).length;
      const sortLabel = sortState.key ? ` Sorted by ${SORT_LABELS[sortState.key]} ${sortState.direction}.` : '';
      const filterWarning = currentViewIsFiltered()
        ? ' Warning: current view is filtered. Use Export Full to save the entire workbook.'
        : '';
      document.getElementById('rowsInfo').textContent = `Showing ${rows.length} row(s) from ${fileCount || 0} visible file(s) (${visibleCount}/${totalFiles} selected).${sortLabel}${filterWarning}`;
      updateAggBar(rows);

      if (!rows.length) {
        document.getElementById('rowsRegion').innerHTML = '<p class="empty">No rows match the current filters.</p>';
        return;
      }

      if (tableView === 'grouped') {
        const grouped = sortedRows.reduce((acc, row) => {
          const key = row._source_file || 'Unknown file';
          if (!acc[key]) {
            acc[key] = [];
          }
          acc[key].push(row);
          return acc;
        }, {});

        const groupEntries = Object.entries(grouped);
        document.getElementById('rowsRegion').innerHTML = `
          <div class="group-list">
            ${groupEntries.map(([sourceFile, groupRows], index) => `
              <details class="group" ${index === 0 ? 'open' : ''}>
                <summary>${sourceFile} (${groupRows.length} row(s))</summary>
                <div class="table-wrap">
                  <table>
                    ${renderColGroup(['post_date', 'description', 'amount', 'balance', 'direction', 'row_confidence', 'category', 'note', 'review'])}
                    <thead>
                      <tr>
                        ${sortableHeader('post_date', 'Date')}
                        ${sortableHeader('description', 'Description')}
                        ${sortableHeader('amount', 'Amount')}
                        ${sortableHeader('balance', 'Balance')}
                        ${sortableHeader('direction', 'Direction')}
                        ${sortableHeader('row_confidence', 'Confidence')}
                        ${sortableHeader('category', 'Category')}
                        ${sortableHeader('note', 'Note')}
                        ${sortableHeader('review', 'Flag')}
                      </tr>
                    </thead>
                    <tbody>
                      ${groupRows.map((row) => `
                        <tr>
                          <td>${row.post_date}</td>
                          <td>${row.description}</td>
                          <td class="numeric">${formatMoney(row.amount)}</td>
                          <td class="numeric">${formatMoney(row.balance)}</td>
                          <td>${row.direction}</td>
                          <td class="numeric">${renderConfidence(row.row_confidence)}</td>
                          <td>
                            <div class="row-category-editor">
                              <select class="row-category" data-row-id="${row.row_id}">${categoryOptions(row.category)}</select>
                              <div class="row-category-actions">
                                <button class="row-category-save-rule" type="button" data-description="${escapeHtmlAttr(row.description)}">Save as Rule…</button>
                                <button class="row-category-uncategorize" type="button">Move to Uncategorized</button>
                              </div>
                            </div>
                          </td>
                          <td><input class="row-note-input" type="text" maxlength="300" data-row-id="${row.row_id}" value="${escapeHtmlAttr(row.note || '')}" placeholder="Add note..."></td>
                          <td>${renderReviewFlag(row)}</td>
                        </tr>
                      `).join('')}
                    </tbody>
                  </table>
                </div>
              </details>
            `).join('')}
          </div>
        `;
        bindSortHeaders();
        bindResizeHandles();
        bindCategoryEditors();
        bindRowNoteEditors();
        return;
      }

      document.getElementById('rowsRegion').innerHTML = `
        <div class="table-wrap">
          <table>
            ${renderColGroup(['source_file', 'post_date', 'description', 'amount', 'balance', 'direction', 'row_confidence', 'category', 'note', 'review'])}
            <thead>
              <tr>
                ${sortableHeader('source_file', 'Source File')}
                ${sortableHeader('post_date', 'Date')}
                ${sortableHeader('description', 'Description')}
                ${sortableHeader('amount', 'Amount')}
                ${sortableHeader('balance', 'Balance')}
                ${sortableHeader('direction', 'Direction')}
                ${sortableHeader('row_confidence', 'Confidence')}
                ${sortableHeader('category', 'Category')}
                ${sortableHeader('note', 'Note')}
                ${sortableHeader('review', 'Flag')}
              </tr>
            </thead>
            <tbody>
              ${sortedRows.map((row) => `
                <tr>
                  <td>${row._source_file || ''}</td>
                  <td>${row.post_date}</td>
                  <td>${row.description}</td>
                  <td class="numeric">${formatMoney(row.amount)}</td>
                  <td class="numeric">${formatMoney(row.balance)}</td>
                  <td>${row.direction}</td>
                  <td class="numeric">${renderConfidence(row.row_confidence)}</td>
                  <td>
                    <div class="row-category-editor">
                      <select class="row-category" data-row-id="${row.row_id}">${categoryOptions(row.category)}</select>
                      <div class="row-category-actions">
                        <button class="row-category-save-rule" type="button" data-description="${escapeHtmlAttr(row.description)}">Save as Rule…</button>
                        <button class="row-category-uncategorize" type="button">Move to Uncategorized</button>
                      </div>
                    </div>
                  </td>
                  <td><input class="row-note-input" type="text" maxlength="300" data-row-id="${row.row_id}" value="${escapeHtmlAttr(row.note || '')}" placeholder="Add note..."></td>
                  <td>${renderReviewFlag(row)}</td>
                </tr>
              `).join('')}
            </tbody>
          </table>
        </div>
      `;
      bindSortHeaders();
      bindResizeHandles();
      bindCategoryEditors();
      bindRowNoteEditors();
    }

    async function fileToPayload(file) {
      const dataUrl = await new Promise((resolve, reject) => {
      const reader = new FileReader();
      reader.onload = () => resolve(reader.result);
      reader.onerror = () => reject(reader.error || new Error('Failed to read file'));
      reader.readAsDataURL(file);
      });
      const parts = String(dataUrl).split(',', 2);
      return { name: file.name, content: parts[1] || '' };
    }

    async function uploadPayload(payload, onUploadProgress, onProcessingStart) {
      return new Promise((resolve, reject) => {
        const request = new XMLHttpRequest();
        request.open('POST', '/api/upload');
        request.setRequestHeader('Content-Type', 'application/json');

        request.upload.onprogress = (event) => {
          if (event.lengthComputable && typeof onUploadProgress === 'function' && event.total > 0) {
            onUploadProgress(event.loaded / event.total);
          }
        };

        request.upload.onload = () => {
          if (typeof onProcessingStart === 'function') {
            onProcessingStart();
          }
        };

        request.onload = () => {
          const text = request.responseText || '';
          let data = null;
          if (text) {
            try {
              data = JSON.parse(text);
            } catch (error) {
              // Leave as null; caller will use raw text.
            }
          }

          resolve({
            ok: request.status >= 200 && request.status < 300,
            status: request.status,
            data,
            text,
          });
        };

        request.onerror = () => {
          reject(new Error('Upload failed. Check your network and try again.'));
        };

        request.send(JSON.stringify(payload));
      });
    }

    async function waitForUploadJob(jobId, fileCount, onProgress) {
      for (;;) {
        const response = await fetch(`/api/upload/jobs/${encodeURIComponent(jobId)}`);
        if (!response.ok) {
          throw new Error(await parseApiError(response, 'Failed to read upload progress.'));
        }

        const payload = await response.json();
        if (typeof onProgress === 'function') {
          onProgress(payload);
        }

        if (payload.status === 'completed') {
          return payload;
        }
        if (payload.status === 'failed') {
          throw new Error(payload.error || 'Upload processing failed.');
        }

        await new Promise((resolve) => window.setTimeout(resolve, 140));
      }
    }

    async function readDirectoryEntries(reader) {
      return new Promise((resolve, reject) => {
        reader.readEntries(resolve, reject);
      });
    }

    async function fileFromEntry(entry) {
      return new Promise((resolve, reject) => {
        entry.file(resolve, reject);
      });
    }

    async function collectFilesFromEntry(entry) {
      if (!entry) {
        return [];
      }
      if (entry.isFile) {
        const file = await fileFromEntry(entry);
        return file ? [file] : [];
      }
      if (!entry.isDirectory) {
        return [];
      }

      const reader = entry.createReader();
      const files = [];
      while (true) {
        const entries = await readDirectoryEntries(reader);
        if (!entries.length) {
          break;
        }
        for (const child of entries) {
          const childFiles = await collectFilesFromEntry(child);
          files.push(...childFiles);
        }
      }
      return files;
    }

    async function extractDroppedFiles(dataTransfer) {
      const items = Array.from(dataTransfer?.items || []);
      if (!items.length) {
        return Array.from(dataTransfer?.files || []);
      }

      const fileGroups = await Promise.all(items.map(async (item) => {
        if (item.kind !== 'file') {
          return [];
        }

        const entry = item.webkitGetAsEntry ? item.webkitGetAsEntry() : null;
        if (entry) {
          return collectFilesFromEntry(entry);
        }

        const file = item.getAsFile();
        return file ? [file] : [];
      }));

      return fileGroups.flat();
    }

    async function uploadFiles(fileList) {
      const ACCEPTED = ['.pdf', '.csv', '.xlsx'];
      const files = Array.from(fileList || []).filter((file) => {
        const ext = file.name.toLowerCase().substring(file.name.lastIndexOf('.'));
        return ACCEPTED.includes(ext);
      });
      if (!files.length) {
        setStatus('Only PDF, CSV, and XLSX files are accepted.');
        return;
      }

      setUploadProgress(true, `Preparing ${files.length} file(s)...`, 0, false);
      try {
        let encodedCount = 0;
        const payloadFiles = await Promise.all(files.map(async (file) => {
          const encoded = await fileToPayload(file);
          encodedCount += 1;
          const prepPercent = (encodedCount / files.length) * 45;
          setUploadProgress(true, `Preparing ${files.length} file(s)...`, prepPercent, false);
          return encoded;
        }));

        setStatus(`Uploading ${files.length} file(s)...`);
        setUploadProgress(true, `Uploading ${files.length} file(s)...`, 45, false);

        const response = await uploadPayload(
          { files: payloadFiles },
          (fraction) => {
            const uploadPercent = 45 + (Math.max(0, Math.min(1, fraction)) * 45);
            setUploadProgress(true, `Uploading ${files.length} file(s)...`, uploadPercent, false);
          },
          () => {
            setUploadProgress(true, `Parsing ${files.length} file(s)...`, 0, true);
            setStatus(`Parsing ${files.length} file(s)...`);
          }
        );

        if (!response.ok) {
          const errorMessage = response.data?.detail || response.text || 'Upload failed.';
          setStatus(String(errorMessage));
          return;
        }

        const jobId = String(response.data?.job_id || '');
        if (!jobId) {
          throw new Error('Upload did not return a job id.');
        }

        const result = await waitForUploadJob(jobId, files.length, (job) => {
          const parsed = Number(job.parsed_files || 0);
          const total = Number(job.total_files || files.length || 1);
          const parsePercent = Math.max(0, Math.min(100, Number(job.progress || 0)));
          const overallPercent = 90 + (parsePercent * 0.1);
          setUploadProgress(true, `Parsing ${total} file(s)... ${parsed}/${total}`, overallPercent, false);
          setStatus(`Parsing ${total} file(s)... ${parsed}/${total}`);
        });

        session = result.session || result;
        const loadedCount = Number(result.loaded_count ?? 0);
        const warnings = Array.isArray(result.warnings) ? result.warnings : [];
        setUploadWarnings(warnings);
        activeIndex = Math.max(0, session.files.length - loadedCount);

        renderSummary(session.summary);
        renderFileList(session.files);
        renderActiveFile();
        if (warnings.length) {
          setStatus(`Loaded ${loadedCount} file(s). Skipped ${warnings.length} duplicate file(s). See notices in the sidebar.`);
        } else {
          setStatus(`Loaded ${loadedCount} file(s).`);
        }
      } catch (error) {
        setStatus(error?.message || 'Upload failed.');
      } finally {
        setUploadProgress(false);
      }
    }

    async function clearSession() {
      const response = await fetch('/api/session', { method: 'DELETE' });
      session = await response.json();
      activeIndex = 0;
      document.getElementById('tableView').checked = false;
      fileVisibility = {};
      setUploadWarnings([]);
      renderSummary(session.summary);
      renderFileList(session.files);
      renderActiveFile();
      setStatus('Cleared current review session.');
    }

    async function unloadFile(index) {
      const file = (session.files || [])[index];
      if (!file) {
        setStatus('File is no longer available.');
        return;
      }

      const sourceFile = String(file.source_file || '');
      if (!sourceFile) {
        setStatus('Unable to unload file: missing source name.');
        return;
      }

      const ok = window.confirm(`Unload "${sourceFile}" from this review session?`);
      if (!ok) {
        return;
      }

      const response = await fetch('/api/files/unload', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ source_file: sourceFile }),
      });

      if (!response.ok) {
        const error = await response.text();
        setStatus(error || 'Failed to unload file.');
        return;
      }

      session = await response.json();
      delete fileVisibility[sourceFile];
      activeIndex = Math.min(activeIndex, Math.max(0, session.files.length - 1));
      renderSummary(session.summary);
      renderFileList(session.files);
      renderActiveFile();
      setStatus(`Unloaded ${sourceFile}.`);
    }

    function exportSession(format, mode = 'full') {
      if (!session.files.length) {
      setStatus('Nothing to export yet.');
      return;
      }
      window.location.href = `/api/export?${currentExportQuery(format, mode)}`;
      const scope = mode === 'view' ? 'current view' : 'full workbook';
      setStatus(`Downloading ${format.toUpperCase()} export (${scope})...`);
    }

    function wireDropzone() {
      const dropzone = document.getElementById('dropzone');
      const fileInput = document.getElementById('fileInput');
      const folderInput = document.getElementById('folderInput');
      const browseButton = document.getElementById('browseButton');
      const browseFolderButton = document.getElementById('browseFolderButton');
      const clearButton = document.getElementById('clearButton');

      browseButton.addEventListener('click', () => fileInput.click());
      browseFolderButton.addEventListener('click', () => folderInput.click());
      fileInput.addEventListener('change', () => uploadFiles(fileInput.files));
      folderInput.addEventListener('change', () => uploadFiles(folderInput.files));
      clearButton.addEventListener('click', clearSession);

      ['dragenter', 'dragover'].forEach((eventName) => {
      dropzone.addEventListener(eventName, (event) => {
        event.preventDefault();
        dropzone.classList.add('active');
      });
      });

      ['dragleave', 'drop'].forEach((eventName) => {
      dropzone.addEventListener(eventName, (event) => {
        event.preventDefault();
        dropzone.classList.remove('active');
      });
      });

      dropzone.addEventListener('drop', async (event) => {
      const files = await extractDroppedFiles(event.dataTransfer);
      uploadFiles(files);
      });
    }

    async function init() {
      await refreshLicenseState();
      const response = await fetch('/api/session');
      session = await response.json();
      if (licenseState?.features?.category_editor) {
        await loadCategoryEditor();
      }
      refreshCategoryFilterOptions();
      renderSummary(session.summary);
      renderFileList(session.files);
      renderActiveFile();
      document.getElementById('searchInput').addEventListener('input', renderRows);

      document.getElementById('tableView').addEventListener('change', renderRows);
      document.getElementById('startDate').addEventListener('change', renderRows);
      document.getElementById('endDate').addEventListener('change', renderRows);
      document.getElementById('categoryFilter').addEventListener('change', renderRows);
      document.getElementById('aggFunc').addEventListener('change', renderRows);
      document.getElementById('addCategoryButton').addEventListener('click', addCategory);
      document.getElementById('openCategoryEditorButton').addEventListener('click', () => {
        if (!licenseState?.features?.category_editor) {
          setStatus('Category editor is a Pro feature. Open License to activate Pro.');
          return;
        }
        document.getElementById('categoryEditorBackdrop').classList.add('open');
      });
      document.getElementById('editorCategorySelect').addEventListener('change', (event) => {
        populateEditorFromSelection(event.target.value);
      });
      document.getElementById('editorNewButton').addEventListener('click', () => {
        document.getElementById('editorCategorySelect').value = '';
        populateEditorFromSelection('');
      });
      document.getElementById('editorSaveButton').addEventListener('click', saveCategoryEditorTerms);
      document.getElementById('editorDeleteButton').addEventListener('click', deleteCategoryEditorOverride);
      document.getElementById('categoryEditorClose').addEventListener('click', () => {
        document.getElementById('categoryEditorBackdrop').classList.remove('open');
      });
      document.getElementById('categoryEditorBackdrop').addEventListener('click', (e) => {
        if (e.target === document.getElementById('categoryEditorBackdrop')) {
          document.getElementById('categoryEditorBackdrop').classList.remove('open');
        }
      });
      document.getElementById('newCategoryInput').addEventListener('keydown', (event) => {
        if (event.key === 'Enter') {
          event.preventDefault();
          addCategory();
        }
      });
      document.getElementById('exportPrimaryButton').addEventListener('click', () => exportSession('xlsx', 'full'));
      const exportMenu = document.getElementById('exportMenu');
      const exportToggle = document.getElementById('exportMenuToggle');
      const exportSplit = document.getElementById('exportSplit');
      exportToggle.addEventListener('click', (event) => {
        event.stopPropagation();
        const open = exportMenu.classList.toggle('open');
        exportToggle.setAttribute('aria-expanded', open ? 'true' : 'false');
      });
      exportMenu.querySelectorAll('[data-export-format]').forEach((button) => {
        button.addEventListener('click', () => {
          const format = button.dataset.exportFormat;
          const mode = button.dataset.exportMode;
          exportMenu.classList.remove('open');
          exportToggle.setAttribute('aria-expanded', 'false');
          exportSession(format, mode);
        });
      });
      document.addEventListener('click', (event) => {
        if (!exportSplit.contains(event.target)) {
          exportMenu.classList.remove('open');
          exportToggle.setAttribute('aria-expanded', 'false');
        }
      });
      document.addEventListener('keydown', (event) => {
        if (event.key === 'Escape') {
          exportMenu.classList.remove('open');
          exportToggle.setAttribute('aria-expanded', 'false');
        }
      });
      wireDropzone();
      setUploadWarnings([]);
      setStatus(session.files.length ? 'Session loaded.' : 'Drop files/folders (PDF, CSV, XLSX) here, or use the Choose Files/Folder buttons to begin.');
    }

    // Save as Rule modal helpers
    function escapeForRegex(str) {
      const backslash = String.fromCharCode(92);
      const specials = new Set(['.', '*', '+', '?', '^', '$', '{', '}', '(', ')', '|', '[', ']', backslash]);
      let out = '';
      for (const ch of str) {
        out += specials.has(ch) ? (backslash + ch) : ch;
      }
      return out;
    }

    function suggestPattern(description) {
      return escapeForRegex(description.trim());
    }

    function openSaveRuleModal(rowId, description, category) {
      document.getElementById('saveRuleName').value = category || '';
      document.getElementById('saveRulePatterns').value = suggestPattern(description);
      document.getElementById('saveRulePriority').value = '65';
      document.getElementById('saveRuleModalBackdrop').classList.add('open');
      document.getElementById('saveRuleName').focus();
    }

    function closeSaveRuleModal() {
      document.getElementById('saveRuleModalBackdrop').classList.remove('open');
    }

    async function submitSaveRule() {
      const name = normalizeCategory(document.getElementById('saveRuleName').value);
      if (!name) { setStatus('Enter a category name before saving.'); return; }
      const patternsRaw = document.getElementById('saveRulePatterns').value;
      const patterns = patternsRaw.split('\n').map(s => s.trim()).filter(Boolean);
      if (!patterns.length) { setStatus('Enter at least one pattern.'); return; }
      const priority = parseInt(document.getElementById('saveRulePriority').value, 10) || 65;

      const response = await fetch('/api/categories/save-rule', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ name, patterns, priority }),
      });
      if (!response.ok) {
        setStatus(await response.text() || 'Failed to save category rule.');
        return;
      }
      const payload = await response.json();
      session = payload.session;
      closeSaveRuleModal();
      await loadCategoryEditor();
      refreshCategoryFilterOptions();
      renderSummary(session.summary);
      renderFileList(session.files);
      renderActiveFile();
      const n = payload.applied_count;
      setStatus(`Rule saved: "${name}" - applied to ${n} row${n === 1 ? '' : 's'}.`);
    }
    // End Save as Rule modal helpers

    init();

    // Diagnostics modal
    (function() {
      const backdrop = document.getElementById('diagModalBackdrop');
      const closeBtn = document.getElementById('diagModalClose');

      closeBtn.addEventListener('click', function() {
        backdrop.classList.remove('open');
      });
      backdrop.addEventListener('click', function(e) {
        if (e.target === backdrop) backdrop.classList.remove('open');
      });
      document.addEventListener('keydown', function(e) {
        if (e.key === 'Escape') {
          backdrop.classList.remove('open');
          document.getElementById('categoryEditorBackdrop').classList.remove('open');
        }
      });
    })();

    // README modal
    (function() {
      const link = document.getElementById('readmeLink');
      const backdrop = document.getElementById('readmeModalBackdrop');
      const closeBtn = document.getElementById('readmeModalClose');
      const content = document.getElementById('readmeContent');

      if (!link || !backdrop || !closeBtn || !content) {
        return;
      }

      async function openReadmeModal() {
        content.textContent = 'Loading README...';
        backdrop.classList.add('open');
        try {
          const response = await fetch('/api/readme');
          if (!response.ok) {
            const message = await response.text();
            content.textContent = message || 'Failed to load README.';
            return;
          }
          const payload = await response.json();
          const rendered = String(payload.rendered_html || '').trim();
          if (rendered) {
            content.innerHTML = rendered;
          } else {
            content.textContent = String(payload.content || 'README is empty.');
          }
        } catch (error) {
          content.textContent = error instanceof Error ? error.message : 'Failed to load README.';
        }
      }

      function closeReadmeModal() {
        backdrop.classList.remove('open');
      }

      link.addEventListener('click', function(event) {
        event.preventDefault();
        openReadmeModal();
      });

      closeBtn.addEventListener('click', closeReadmeModal);
      backdrop.addEventListener('click', function(event) {
        if (event.target === backdrop) {
          closeReadmeModal();
        }
      });
    })();

    // Help modal
    (function() {
      const link = document.getElementById('helpLink');
      const backdrop = document.getElementById('helpModalBackdrop');
      const closeBtn = document.getElementById('helpModalClose');
      const content = document.getElementById('helpContent');

      if (!link || !backdrop || !closeBtn || !content) {
        return;
      }

      async function openHelpModal() {
        content.textContent = 'Loading help...';
        backdrop.classList.add('open');
        try {
          const response = await fetch('/api/help');
          if (!response.ok) {
            const message = await response.text();
            content.textContent = message || 'Failed to load Help.';
            return;
          }
          const payload = await response.json();
          const rendered = String(payload.rendered_html || '').trim();
          if (rendered) {
            content.innerHTML = rendered;
          } else {
            content.textContent = String(payload.content || 'Help is empty.');
          }
        } catch (error) {
          content.textContent = error instanceof Error ? error.message : 'Failed to load Help.';
        }
      }

      function closeHelpModal() {
        backdrop.classList.remove('open');
      }

      link.addEventListener('click', function(event) {
        event.preventDefault();
        openHelpModal();
      });

      closeBtn.addEventListener('click', closeHelpModal);
      backdrop.addEventListener('click', function(event) {
        if (event.target === backdrop) {
          closeHelpModal();
        }
      });
    })();

    // License modal
    (function() {
      const link = document.getElementById('licenseLink');
      const backdrop = document.getElementById('licenseModalBackdrop');
      const closeBtn = document.getElementById('licenseModalClose');
      const publicKeyToggle = document.getElementById('licensePublicKeyToggle');
      const publicKeySection = document.getElementById('licensePublicKeySection');
      const publicKeyInput = document.getElementById('licensePublicKeyInput');
      const nameInput = document.getElementById('licenseNameInput');
      const emailInput = document.getElementById('licenseEmailInput');
      const tokenInput = document.getElementById('licenseTokenInput');
      const testButton = document.getElementById('licenseTestButton');
      const saveButton = document.getElementById('licenseSaveButton');
      const clearButton = document.getElementById('licenseClearButton');
      const statusLine = document.getElementById('licenseStatusLine');

      if (!link || !backdrop || !closeBtn || !publicKeyToggle || !publicKeySection || !publicKeyInput || !nameInput || !emailInput || !tokenInput || !testButton || !saveButton || !clearButton || !statusLine) {
        return;
      }

      function setPublicKeyVisibility(visible) {
        const isVisible = !!visible;
        publicKeySection.hidden = !isVisible;
        publicKeyToggle.setAttribute('aria-expanded', isVisible ? 'true' : 'false');
        publicKeyToggle.textContent = isVisible ? 'Hide public key' : 'Show public key';
      }

      function setLicenseStatus(message) {
        statusLine.textContent = message || '';
      }

      async function openModal() {
        backdrop.classList.add('open');
        try {
          const info = await refreshLicenseState();
          publicKeyInput.value = info.public_key_pem || '';
          setPublicKeyVisibility(false);
          nameInput.value = info.licensed_to_name || '';
          emailInput.value = info.licensed_to_email || '';
          tokenInput.value = '';
          setLicenseStatus(`Current edition: ${String(info.edition || 'free').toUpperCase()} (${info.licensed_to || 'Community Edition'})`);
        } catch (error) {
          setLicenseStatus(error instanceof Error ? error.message : 'Failed to load license state.');
        }
      }

      function closeModal() {
        backdrop.classList.remove('open');
      }

      async function saveLicense() {
        const token = String(tokenInput.value || '').trim();
        const public_key_pem = String(publicKeyInput.value || '').trim();
        const name = String(nameInput.value || '').trim();
        const email = String(emailInput.value || '').trim();
        if (!token) {
          setLicenseStatus('Paste a license key first.');
          return;
        }

        const response = await fetch('/api/license', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({ token, public_key_pem, name, email }),
        });
        if (!response.ok) {
          setLicenseStatus(await parseApiError(response, 'License validation failed.'));
          return;
        }

        licenseState = await response.json();
        applyLicenseUi();
        setLicenseStatus(`Saved. Active edition: ${String(licenseState.edition || 'free').toUpperCase()}.`);
        setStatus('License saved successfully.');
      }

      async function testLicense() {
        const token = String(tokenInput.value || '').trim();
        const public_key_pem = String(publicKeyInput.value || '').trim();
        const name = String(nameInput.value || '').trim();
        const email = String(emailInput.value || '').trim();
        if (!token) {
          setLicenseStatus('Paste a license key first.');
          return;
        }

        const response = await fetch('/api/license/test', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({ token, public_key_pem, name, email }),
        });
        if (!response.ok) {
          setLicenseStatus(await parseApiError(response, 'License validation failed.'));
          return;
        }

        const payload = await response.json();
        setLicenseStatus(`Valid: ${String(payload.edition || 'free').toUpperCase()} (${payload.licensed_to || 'Community Edition'}).`);
      }

      async function clearLicense() {
        const response = await fetch('/api/license', { method: 'DELETE' });
        if (!response.ok) {
          setLicenseStatus(await parseApiError(response, 'Failed to clear license.'));
          return;
        }

        licenseState = await response.json();
        applyLicenseUi();
        tokenInput.value = '';
        setLicenseStatus('License cleared. Community edition is active.');
        setStatus('Cleared license key.');
      }

      link.addEventListener('click', function(event) {
        event.preventDefault();
        openModal();
      });
      closeBtn.addEventListener('click', closeModal);
      backdrop.addEventListener('click', function(event) {
        if (event.target === backdrop) {
          closeModal();
        }
      });
      saveButton.addEventListener('click', saveLicense);
      testButton.addEventListener('click', testLicense);
      clearButton.addEventListener('click', clearLicense);
      publicKeyToggle.addEventListener('click', function() {
        setPublicKeyVisibility(publicKeySection.hidden);
      });
    })();

    // Save as Rule modal
    (function() {
      const backdrop = document.getElementById('saveRuleModalBackdrop');
      if (!backdrop) {
        return;
      }
      document.getElementById('saveRuleModalClose').addEventListener('click', closeSaveRuleModal);
      document.getElementById('saveRuleCancel').addEventListener('click', closeSaveRuleModal);
      document.getElementById('saveRuleSubmit').addEventListener('click', submitSaveRule);
      backdrop.addEventListener('click', function(e) {
        if (e.target === backdrop) closeSaveRuleModal();
      });
      document.getElementById('saveRulePatterns').addEventListener('keydown', function(e) {
        if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) submitSaveRule();
      });
    })();

    // Sidebar collapse/expand
    (function() {
      const STORAGE_KEY = 'ls_sidebar_collapsed';
      const shell = document.getElementById('appShell');
      const toggleBtn = document.getElementById('sidebarToggle');
      const openTab = document.getElementById('sidebarOpenTab');

      function setCollapsed(collapsed) {
        if (collapsed) {
          shell.classList.add('sidebar-collapsed');
          openTab.style.display = 'flex';
        } else {
          shell.classList.remove('sidebar-collapsed');
          openTab.style.display = 'none';
        }
        try { localStorage.setItem(STORAGE_KEY, collapsed ? '1' : '0'); } catch(e) {}
      }

      toggleBtn.addEventListener('click', function() {
        setCollapsed(!shell.classList.contains('sidebar-collapsed'));
      });
      openTab.addEventListener('click', function() {
        setCollapsed(false);
      });

      // Restore preference
      try {
        if (localStorage.getItem(STORAGE_KEY) === '1') setCollapsed(true);
      } catch(e) {}
    })();
