// editor.js — Phase 1 of editable-cell mode.
//
// Owns the cell-selection + per-cell edit lifecycle. Implements the
// W3C ARIA grid-pattern keyboard semantics:
//
//   - Arrow keys move the selected cell.
//   - Tab / Shift-Tab move right / left, wrapping to next / prev row.
//   - Enter, F2, double-click, or any printable character enter edit
//     mode (Enter and F2 keep the existing value; printable chars
//     replace it; double-click opens with the existing value).
//   - In edit mode: Enter commits and moves down, Tab commits and
//     moves right, Escape cancels (restoring the prior value), blur
//     commits.
//
// Roving tabindex: only the selected cell carries tabindex=0; all
// others are tabindex=-1. This makes the grid a single tab-stop in
// the page's tab order, which is the documented spreadsheet UX.
//
// Edits in this phase live in app.state.drafts and never hit the
// network — Phase 3 wires the row-blur PUT.
(function (app) {
    'use strict';

    // --- Helpers ------------------------------------------------------

    function tableEl()    { return document.getElementById('table-root'); }
    function cellAt(r, c) { return cellsByRowCol(r, c); }

    // The displayed table is filtered+sorted; selection is keyed by
    // VISIBLE row index, not row id, so arrow keys behave intuitively
    // even after sort / filter changes (the cell at row 3 column 2
    // stays at row 3 column 2 even if the underlying row id moved).
    // This is how Excel and Google Sheets behave too.
    function cellsByRowCol(r, c) {
        const t = tableEl();
        if (!t) return null;
        const tbody = t.querySelector('tbody');
        if (!tbody) return null;
        const tr = tbody.children[r];
        if (!tr) return null;
        return tr.querySelector('[role="gridcell"][data-col-idx="' + c + '"]');
    }

    function isPrintableKey(ev) {
        // A "printable" key produces a single character of text — e.g.
        // 'a', '7', '$'. Function keys, arrows, modifiers etc. either
        // have multi-char `key` values ('ArrowDown') or are non-text.
        // ev.ctrlKey / metaKey suppress so Cmd-A et al. don't trigger
        // edit mode.
        if (ev.key.length !== 1) return false;
        if (ev.ctrlKey || ev.metaKey || ev.altKey) return false;
        return true;
    }

    function rowCount() {
        const t = tableEl();
        if (!t) return 0;
        return t.querySelectorAll('tbody > tr').length;
    }

    function colCount() {
        const cols = (app.context && app.context.columns) || [];
        return Array.isArray(cols) ? cols.length : 0;
    }

    function colAt(c) {
        const cols = (app.context && app.context.columns) || [];
        return cols[c] || null;
    }

    function rowDataAt(r) {
        // The visible row at index r. Walk the rendered tbody to find
        // its data-row-id, then look up the row in app.state.rows.
        // app.state.rows holds the SORTED+FILTERED current view (kept
        // in sync by main.js paint()).
        const t = tableEl();
        if (!t) return null;
        const tr = t.querySelectorAll('tbody > tr')[r];
        if (!tr) return null;
        const rowId = tr.getAttribute('data-row-id');
        if (rowId == null) return null;
        const all = app.state.rows || [];
        for (let i = 0; i < all.length; i++) {
            if (rowKey(all[i]) === rowId) {
                return all[i];
            }
        }
        return null;
    }

    function rowKey(row) {
        // Stable per-row identity. Each context row has a `url` (the
        // <id>.yaml.html re-edit URL); the file basename inside that
        // URL is unique per directory and survives sort/filter.
        if (!row || !row.url) return '';
        return row.url;
    }

    // --- Draft buffer -------------------------------------------------

    function getDraft(rowId, field) {
        const r = app.state.drafts[rowId];
        if (!r) return undefined;
        return r[field];
    }

    function setDraft(rowId, field, value) {
        if (!app.state.drafts[rowId]) {
            app.state.drafts[rowId] = {};
        }
        app.state.drafts[rowId][field] = value;
    }

    function clearDraftField(rowId, field) {
        const r = app.state.drafts[rowId];
        if (!r) return;
        delete r[field];
        if (Object.keys(r).length === 0) {
            delete app.state.drafts[rowId];
        }
    }

    function effectiveCellValue(row, col) {
        // Display draft value if present; otherwise the row's stored
        // value. Used by render to keep the visible cell content in
        // sync with uncommitted edits.
        const drafted = getDraft(rowKey(row), col.field);
        if (drafted !== undefined) {
            return drafted;
        }
        return app.modules.util.resolveField(row.data, col.field);
    }

    // --- Selection (roving tabindex) ----------------------------------

    function setSelected(r, c, opts) {
        opts = opts || {};
        const total = rowCount();
        const cols = colCount();
        if (total === 0 || cols === 0) {
            app.state.selected = null;
            notifySelectionChanged();
            return;
        }
        if (r < 0) r = 0;
        if (r > total - 1) r = total - 1;
        if (c < 0) c = 0;
        if (c > cols - 1) c = cols - 1;

        const t = tableEl();
        if (t) {
            const all = t.querySelectorAll('[role="gridcell"]');
            for (let i = 0; i < all.length; i++) {
                all[i].setAttribute('tabindex', '-1');
                all[i].classList.remove('zddc-table__cell--selected');
            }
        }
        const target = cellAt(r, c);
        if (target) {
            target.setAttribute('tabindex', '0');
            target.classList.add('zddc-table__cell--selected');
            if (!opts.noFocus) {
                target.focus({ preventScroll: false });
            }
        }
        app.state.selected = { row: r, col: c };
        // Plain selection moves clear the multi-cell range. Range
        // operations (Shift+click, Shift+arrow) pass keepRange so the
        // anchor stays put while the focus cell moves.
        if (!opts.keepRange) {
            clearRange();
        }
        notifySelectionChanged();
    }

    function notifySelectionChanged() {
        // Phase 3 wires the row-blur save trigger here. save module is
        // optional in test fixtures that don't include it.
        const save = app.modules.save;
        if (save && typeof save.onSelectionChanged === 'function') {
            save.onSelectionChanged(app.state.selected);
        }
    }

    function clearSelection() {
        const t = tableEl();
        if (t) {
            const all = t.querySelectorAll('[role="gridcell"]');
            for (let i = 0; i < all.length; i++) {
                all[i].setAttribute('tabindex', '-1');
                all[i].classList.remove('zddc-table__cell--selected');
            }
        }
        app.state.selected = null;
    }

    // --- Edit mode ----------------------------------------------------

    function enterEdit(initial) {
        if (!app.state.selected) return;
        if (app.state.editing) return;
        const { row: r, col: c } = app.state.selected;
        const cell = cellAt(r, c);
        if (!cell) return;
        const row = rowDataAt(r);
        const col = colAt(c);
        if (!row || !col) return;

        const propSchema = propertySchemaFor(col);

        // Complex-type cells (nested object, generic array, oneOf)
        // can't be inline-edited cleanly — punt to the row's form
        // editor in a side panel / new page. Phase 2 ships the
        // navigation; Phase 5 may add a side-panel mount.
        if (isComplexSchema(propSchema)) {
            navigateToRowForm(row);
            return;
        }

        const currentValue = effectiveCellValue(row, col);
        const widget = makeWidget(propSchema, col, initial != null ? initial : currentValue);
        const inputEl = widget.element;
        inputEl.classList.add('zddc-table__cell-input');
        inputEl.setAttribute('aria-label', 'Edit ' + (col.title || col.field));

        // Replace the cell's text content with the editor widget.
        // Stash the original text in dataset so cancel can restore it
        // verbatim without re-running the formatCell logic.
        cell.setAttribute('data-display', cell.textContent || '');
        cell.textContent = '';
        cell.appendChild(inputEl);
        widget.focus();

        app.state.editing = true;

        function commit() {
            if (!app.state.editing) return;
            const newValue = widget.getValue();
            const oldRaw = app.modules.util.resolveField(row.data, col.field);
            // Compare by JSON-string equality so number 42 == "42"
            // entered into a number input doesn't false-positive as
            // a change. resolveField already returns the raw typed
            // value from row.data.
            if (sameValue(oldRaw, newValue)) {
                clearDraftField(rowKey(row), col.field);
            } else {
                // Capture the prior draft value (or stored value if
                // no draft) for undo. Lets Ctrl+Z restore intermediate
                // state: e.g. typing A → B → C and undoing returns to
                // B, not all the way back to the row's stored value.
                const priorDraft = getDraft(rowKey(row), col.field);
                const undoOld = (priorDraft !== undefined) ? priorDraft : oldRaw;
                setDraft(rowKey(row), col.field, newValue);
                const undoMod = app.modules.undo;
                if (undoMod) {
                    undoMod.push({
                        cells: [{
                            rowId: rowKey(row),
                            field: col.field,
                            oldValue: undoOld,
                            newValue: newValue,
                        }],
                    });
                }
            }
            tearDown(newValue);
        }

        function cancel() {
            tearDown(null);  // null = restore from data-display, no draft change
        }

        function tearDown(displayValue) {
            inputEl.removeEventListener('keydown', onKey);
            inputEl.removeEventListener('blur', onBlur);
            const display = (displayValue !== undefined && displayValue !== null)
                ? renderableText(displayValue, col)
                : (cell.getAttribute('data-display') || '');
            cell.removeAttribute('data-display');
            cell.textContent = display;
            app.state.editing = false;
            cell.focus({ preventScroll: false });
        }

        function onKey(ev) {
            if (ev.key === 'Enter') {
                ev.preventDefault();
                ev.stopPropagation(); // don't let the table's onCellKey re-handle it
                commit();
                setSelected(r + 1, c);
            } else if (ev.key === 'Escape') {
                ev.preventDefault();
                ev.stopPropagation();
                cancel();
            } else if (ev.key === 'Tab') {
                ev.preventDefault();
                ev.stopPropagation();
                commit();
                if (ev.shiftKey) {
                    moveSelection('left-wrap');
                } else {
                    moveSelection('right-wrap');
                }
            }
            // Other keys: stay in edit mode, let the input handle them.
        }

        function onBlur(_ev) {
            // Blur (focus moved elsewhere). Commit any pending value.
            // Schedule via setTimeout(0) so a programmatic refocus by
            // tearDown→cell.focus doesn't re-fire blur during teardown.
            if (app.state.editing) {
                commit();
            }
        }

        inputEl.addEventListener('keydown', onKey);
        inputEl.addEventListener('blur', onBlur);
    }

    function renderableText(value, col) {
        return app.modules.util.formatCell(value, col.format);
    }

    // --- Schema → editor widget factory --------------------------------

    function propertySchemaFor(col) {
        // Walk the row schema for this column's field. Returns null
        // when no schema is present (best-effort: cells fall back to
        // plain text editors). Supports a single dot-separated path
        // — `properties.a.properties.b` for `field: "a.b"` — to mirror
        // the existing util.resolveField conventions.
        const ctx = app.context || {};
        if (!ctx.rowSchema) return null;
        const parts = String(col.field || '').split('.').filter(Boolean);
        let s = ctx.rowSchema;
        for (let i = 0; i < parts.length; i++) {
            if (!s || !s.properties || !s.properties[parts[i]]) return null;
            s = s.properties[parts[i]];
        }
        return s;
    }

    function isComplexSchema(s) {
        if (!s) return false;
        if (Array.isArray(s.oneOf) && s.oneOf.length > 0) return true;
        if (Array.isArray(s.anyOf) && s.anyOf.length > 0) return true;
        if (Array.isArray(s.allOf) && s.allOf.length > 0) return true;
        if (s.type === 'object') return true;
        if (s.type === 'array') {
            // Multi-select-friendly arrays (string-enum + uniqueItems)
            // get inline editing; everything else is complex.
            const items = s.items || {};
            const isMultiSelect = items.type === 'string'
                && Array.isArray(items.enum) && items.enum.length > 0
                && s.uniqueItems === true;
            return !isMultiSelect;
        }
        return false;
    }

    function makeWidget(propSchema, col, initialValue) {
        // Prefers explicit JSON Schema hints; falls back to column-spec
        // hints (col.format / col.enum) for tables without a form.yaml;
        // defaults to a plain text input.
        const s = propSchema || {};
        const colHint = col || {};

        // Boolean → checkbox.
        if (s.type === 'boolean') {
            return widgetCheckbox(initialValue);
        }

        // Enum (string with explicit choices) → select dropdown.
        const enumChoices = (Array.isArray(s.enum) && s.enum)
            || (Array.isArray(colHint.enum) && colHint.enum)
            || null;
        if (enumChoices) {
            return widgetSelect(enumChoices, initialValue);
        }

        // Multi-select (array of string-enum with uniqueItems).
        if (s.type === 'array'
            && s.items && s.items.type === 'string'
            && Array.isArray(s.items.enum) && s.uniqueItems === true) {
            return widgetMultiSelect(s.items.enum, initialValue);
        }

        // Number / integer → number input with min/max/step.
        if (s.type === 'number' || s.type === 'integer'
            || colHint.format === 'number' || colHint.format === 'integer') {
            return widgetNumber(s, initialValue);
        }

        // Date / date-time / email — typed inputs the browser can
        // help validate.
        const fmt = s.format || colHint.format;
        if (fmt === 'date')      return widgetTyped('date',  initialValue);
        if (fmt === 'date-time') return widgetTyped('datetime-local', initialValue);
        if (fmt === 'email')     return widgetTyped('email', initialValue);

        // Long text → textarea (still inline; Phase 5 may add expand).
        if (s.type === 'string' && Number(s.maxLength) > 200) {
            return widgetTextarea(initialValue);
        }

        // Default: plain text input.
        return widgetText(initialValue);
    }

    function widgetText(initial) {
        const el = document.createElement('input');
        el.type = 'text';
        el.value = stringify(initial);
        return {
            element: el,
            getValue: () => el.value,
            focus: () => { el.focus(); try { el.setSelectionRange(el.value.length, el.value.length); } catch (_) {} }
        };
    }

    function widgetTextarea(initial) {
        const el = document.createElement('textarea');
        el.rows = 1;
        el.value = stringify(initial);
        return {
            element: el,
            getValue: () => el.value,
            focus: () => { el.focus(); try { el.setSelectionRange(el.value.length, el.value.length); } catch (_) {} }
        };
    }

    function widgetTyped(htmlType, initial) {
        const el = document.createElement('input');
        el.type = htmlType;
        el.value = stringify(initial);
        return {
            element: el,
            getValue: () => el.value,
            focus: () => el.focus()
        };
    }

    function widgetNumber(s, initial) {
        const el = document.createElement('input');
        el.type = 'number';
        if (s.minimum != null) el.min = String(s.minimum);
        if (s.maximum != null) el.max = String(s.maximum);
        if (s.type === 'integer') el.step = '1';
        else if (s.multipleOf != null) el.step = String(s.multipleOf);
        el.value = (initial == null || initial === '') ? '' : String(initial);
        return {
            element: el,
            getValue: () => {
                const v = el.value;
                if (v === '') return null;
                const n = Number(v);
                return Number.isNaN(n) ? v : n;
            },
            focus: () => el.focus()
        };
    }

    function widgetCheckbox(initial) {
        const el = document.createElement('input');
        el.type = 'checkbox';
        el.checked = initial === true || initial === 'true';
        return {
            element: el,
            getValue: () => el.checked,
            focus: () => el.focus()
        };
    }

    function widgetSelect(choices, initial) {
        const el = document.createElement('select');
        // Empty option lets the cell go back to "unset" without typing.
        const empty = document.createElement('option');
        empty.value = '';
        empty.textContent = '—';
        el.appendChild(empty);
        for (let i = 0; i < choices.length; i++) {
            const opt = document.createElement('option');
            opt.value = String(choices[i]);
            opt.textContent = String(choices[i]);
            el.appendChild(opt);
        }
        el.value = initial == null ? '' : String(initial);
        return {
            element: el,
            getValue: () => (el.value === '' ? null : el.value),
            focus: () => el.focus()
        };
    }

    function widgetMultiSelect(choices, initial) {
        const el = document.createElement('select');
        el.multiple = true;
        el.size = Math.min(6, choices.length);
        const initialSet = {};
        const initArr = Array.isArray(initial) ? initial : [];
        for (let i = 0; i < initArr.length; i++) initialSet[String(initArr[i])] = true;
        for (let i = 0; i < choices.length; i++) {
            const opt = document.createElement('option');
            opt.value = String(choices[i]);
            opt.textContent = String(choices[i]);
            if (initialSet[opt.value]) opt.selected = true;
            el.appendChild(opt);
        }
        return {
            element: el,
            getValue: () => {
                const out = [];
                for (let i = 0; i < el.options.length; i++) {
                    if (el.options[i].selected) out.push(el.options[i].value);
                }
                return out;
            },
            focus: () => el.focus()
        };
    }

    function stringify(v) {
        if (v == null) return '';
        if (typeof v === 'object') {
            try { return JSON.stringify(v); } catch (_) { return String(v); }
        }
        return String(v);
    }

    function sameValue(a, b) {
        if (a === b) return true;
        if (a == null && b == null) return true;
        if (a == null || b == null) return false;
        if (typeof a === 'object' || typeof b === 'object') {
            try { return JSON.stringify(a) === JSON.stringify(b); }
            catch (_) { return false; }
        }
        // Loose-string compare so number 42 == "42" from a text input.
        return String(a) === String(b);
    }

    function navigateToRowForm(row) {
        // Complex-type cells punt to the row's full form editor.
        // The url field on each context row already points at
        // <dir>/<id>.yaml.html — the form-mode re-edit URL.
        if (!row || !row.url) return;
        const nav = (window.tablesApp && window.tablesApp.navigateTo)
            || function (u) { window.location.assign(u); };
        nav(row.url);
    }

    // --- Keyboard nav -------------------------------------------------

    function moveSelection(dir) {
        if (!app.state.selected) return;
        let { row: r, col: c } = app.state.selected;
        const total = rowCount();
        const cols = colCount();
        if (total === 0 || cols === 0) return;

        switch (dir) {
            case 'up':         r = Math.max(0, r - 1); break;
            case 'down':       r = Math.min(total - 1, r + 1); break;
            case 'left':       c = Math.max(0, c - 1); break;
            case 'right':      c = Math.min(cols - 1, c + 1); break;
            case 'home':       c = 0; break;
            case 'end':        c = cols - 1; break;
            case 'home-row':   r = 0; c = 0; break;
            case 'end-row':    r = total - 1; c = cols - 1; break;
            case 'left-wrap':
                if (c > 0) { c--; }
                else if (r > 0) { r--; c = cols - 1; }
                break;
            case 'right-wrap':
                if (c < cols - 1) { c++; }
                else if (r < total - 1) { r++; c = 0; }
                break;
        }
        setSelected(r, c);
    }

    function onCellKey(ev) {
        if (app.state.editing) return; // input owns its own keys
        if (!app.state.selected) return;
        const isRangeKey = ev.shiftKey;

        switch (ev.key) {
            case 'ArrowUp':
                ev.preventDefault();
                isRangeKey ? extendRange('up')    : moveSelection('up');
                return;
            case 'ArrowDown':
                ev.preventDefault();
                isRangeKey ? extendRange('down')  : moveSelection('down');
                return;
            case 'ArrowLeft':
                ev.preventDefault();
                isRangeKey ? extendRange('left')  : moveSelection('left');
                return;
            case 'ArrowRight':
                ev.preventDefault();
                isRangeKey ? extendRange('right') : moveSelection('right');
                return;
            case 'Home':
                ev.preventDefault();
                if (ev.ctrlKey || ev.metaKey) moveSelection('home-row');
                else moveSelection('home');
                return;
            case 'End':
                ev.preventDefault();
                if (ev.ctrlKey || ev.metaKey) moveSelection('end-row');
                else moveSelection('end');
                return;
            case 'Tab':
                ev.preventDefault();
                moveSelection(ev.shiftKey ? 'left-wrap' : 'right-wrap');
                return;
            case 'Enter':
            case 'F2':
                ev.preventDefault();
                enterEdit();
                return;
            case 'Escape':
                ev.preventDefault();
                clearSelection();
                clearRange();
                return;
            case 'Delete':
            case 'Backspace':
                ev.preventDefault();
                bulkClearSelection();
                return;
            case 'd':
            case 'D':
                if (ev.ctrlKey || ev.metaKey) {
                    ev.preventDefault();
                    bulkFill('down');
                    return;
                }
                break;
            case 'r':
            case 'R':
                if (ev.ctrlKey || ev.metaKey) {
                    ev.preventDefault();
                    bulkFill('right');
                    return;
                }
                break;
        }

        if (isPrintableKey(ev)) {
            // Replace value with the typed character (Excel convention).
            ev.preventDefault();
            enterEdit(ev.key);
        }
    }

    // --- Range selection (multi-cell ops) -----------------------------

    function extendRange(dir) {
        if (!app.state.selected) return;
        const range = ensureRange();
        let { row: r, col: c } = range.focus;
        const total = rowCount();
        const cols = colCount();
        switch (dir) {
            case 'up':    r = Math.max(0, r - 1); break;
            case 'down':  r = Math.min(total - 1, r + 1); break;
            case 'left':  c = Math.max(0, c - 1); break;
            case 'right': c = Math.min(cols - 1, c + 1); break;
        }
        range.focus = { row: r, col: c };
        applyRangeSelectionStyles(range);
    }

    function ensureRange() {
        if (!app.state.range) {
            const sel = app.state.selected;
            app.state.range = {
                anchor: { row: sel.row, col: sel.col },
                focus:  { row: sel.row, col: sel.col },
            };
        }
        return app.state.range;
    }

    function clearRange() {
        app.state.range = null;
        const t = tableEl();
        if (!t) return;
        const all = t.querySelectorAll('[role="gridcell"]');
        for (let i = 0; i < all.length; i++) {
            all[i].classList.remove('zddc-table__cell--in-range');
        }
    }

    function applyRangeSelectionStyles(range) {
        const t = tableEl();
        if (!t) return;
        const all = t.querySelectorAll('[role="gridcell"]');
        for (let i = 0; i < all.length; i++) {
            all[i].classList.remove('zddc-table__cell--in-range');
        }
        const r0 = Math.min(range.anchor.row, range.focus.row);
        const r1 = Math.max(range.anchor.row, range.focus.row);
        const c0 = Math.min(range.anchor.col, range.focus.col);
        const c1 = Math.max(range.anchor.col, range.focus.col);
        for (let r = r0; r <= r1; r++) {
            for (let c = c0; c <= c1; c++) {
                const cell = cellAt(r, c);
                if (cell) cell.classList.add('zddc-table__cell--in-range');
            }
        }
    }

    function rangeCells() {
        // Returns an array of {rowIdx, colIdx, row, col} for every
        // cell in the current range — or just the selected cell if
        // no range is active. Skips cells whose row data can't be
        // resolved (defensive).
        const out = [];
        const range = app.state.range;
        if (range) {
            const r0 = Math.min(range.anchor.row, range.focus.row);
            const r1 = Math.max(range.anchor.row, range.focus.row);
            const c0 = Math.min(range.anchor.col, range.focus.col);
            const c1 = Math.max(range.anchor.col, range.focus.col);
            for (let r = r0; r <= r1; r++) {
                const row = rowDataAt(r);
                if (!row) continue;
                for (let c = c0; c <= c1; c++) {
                    const col = colAt(c);
                    if (col) out.push({ rowIdx: r, colIdx: c, row: row, col: col });
                }
            }
            return out;
        }
        if (!app.state.selected) return out;
        const { row: r, col: c } = app.state.selected;
        const row = rowDataAt(r);
        const col = colAt(c);
        if (row && col) out.push({ rowIdx: r, colIdx: c, row: row, col: col });
        return out;
    }

    function bulkClearSelection() {
        // Delete / Backspace in nav mode: clear every selected cell.
        // Pushes one undo Command spanning all affected cells.
        const cells = rangeCells();
        if (cells.length === 0) return;
        const undoCells = [];
        for (let i = 0; i < cells.length; i++) {
            const c = cells[i];
            const oldRaw = app.modules.util.resolveField(c.row.data, c.col.field);
            const priorDraft = getDraft(rowKey(c.row), c.col.field);
            const undoOld = (priorDraft !== undefined) ? priorDraft : oldRaw;
            setDraft(rowKey(c.row), c.col.field, null);
            undoCells.push({
                rowId: rowKey(c.row),
                field: c.col.field,
                oldValue: undoOld,
                newValue: null,
            });
        }
        const undoMod = app.modules.undo;
        if (undoMod) undoMod.push({ cells: undoCells });
        if (typeof app.repaint === 'function') app.repaint();
    }

    function bulkFill(dir) {
        // Ctrl+D fills the top row's values down through the range.
        // Ctrl+R fills the left column's values right through the range.
        // No-op when no range is active (Excel does the same).
        const range = app.state.range;
        if (!range) return;
        const r0 = Math.min(range.anchor.row, range.focus.row);
        const r1 = Math.max(range.anchor.row, range.focus.row);
        const c0 = Math.min(range.anchor.col, range.focus.col);
        const c1 = Math.max(range.anchor.col, range.focus.col);

        const undoCells = [];
        for (let r = r0; r <= r1; r++) {
            const row = rowDataAt(r);
            if (!row) continue;
            for (let c = c0; c <= c1; c++) {
                const col = colAt(c);
                if (!col) continue;
                const srcR = (dir === 'down') ? r0 : r;
                const srcC = (dir === 'right') ? c0 : c;
                if (r === srcR && c === srcC) continue;
                const srcRow = rowDataAt(srcR);
                const srcCol = colAt(srcC);
                if (!srcRow || !srcCol) continue;
                const value = effectiveCellValue(srcRow, srcCol);
                const oldRaw = app.modules.util.resolveField(row.data, col.field);
                const priorDraft = getDraft(rowKey(row), col.field);
                const undoOld = (priorDraft !== undefined) ? priorDraft : oldRaw;
                setDraft(rowKey(row), col.field, value);
                undoCells.push({
                    rowId: rowKey(row),
                    field: col.field,
                    oldValue: undoOld,
                    newValue: value,
                });
            }
        }
        if (undoCells.length > 0) {
            const undoMod = app.modules.undo;
            if (undoMod) undoMod.push({ cells: undoCells });
            if (typeof app.repaint === 'function') app.repaint();
        }
    }

    // --- Wiring -------------------------------------------------------

    function attachToTable() {
        const t = tableEl();
        if (!t) return;
        t.setAttribute('role', 'grid');
        t.addEventListener('keydown', onCellKey);
    }

    function attachToRow(tr, rowId) {
        tr.setAttribute('role', 'row');
        tr.setAttribute('data-row-id', rowId);
    }

    function attachToCell(td, rowIdx, colIdx) {
        td.setAttribute('role', 'gridcell');
        td.setAttribute('data-col-idx', String(colIdx));
        td.setAttribute('data-row-idx', String(rowIdx));
        td.setAttribute('tabindex', '-1');

        td.addEventListener('click', function (ev) {
            ev.stopPropagation();
            if (ev.shiftKey && app.state.selected) {
                // Shift+click extends the range from the existing
                // anchor to the clicked cell.
                const range = ensureRange();
                range.focus = { row: rowIdx, col: colIdx };
                applyRangeSelectionStyles(range);
                // Move tabindex/focus marker to the clicked cell but
                // keep the anchor in place.
                setSelected(rowIdx, colIdx, { keepRange: true });
            } else {
                clearRange();
                setSelected(rowIdx, colIdx);
            }
        });
        td.addEventListener('dblclick', function (ev) {
            ev.stopPropagation();
            clearRange();
            setSelected(rowIdx, colIdx, { noFocus: true });
            enterEdit();
        });
    }

    app.modules.editor = {
        attachToTable: attachToTable,
        attachToRow: attachToRow,
        attachToCell: attachToCell,
        setSelected: setSelected,
        clearSelection: clearSelection,
        moveSelection: moveSelection,
        enterEdit: enterEdit,
        rowKey: rowKey,
        getDraft: getDraft,
        setDraft: setDraft,
        clearDraftField: clearDraftField,
        effectiveCellValue: effectiveCellValue
    };
})(window.tablesApp);
