// save.js — Phase 3 of editable-cell mode.
//
// Row-level batch save on row-blur. While the user is editing cells
// inside a row, draft values accumulate in app.state.drafts. When the
// editor's selection moves to a different row (or focus leaves the
// grid entirely), this module fires one PUT for the row that lost
// focus, with full merged data + If-Match for the row's tracked ETag.
//
// Three response paths:
//
//   - 200 / 201 / 202: success or queued-offline (cache outbox).
//     Drafts clear, row.data merges, new ETag captured. Row's
//     "dirty" indicator drops.
//
//   - 412 Precondition Failed: someone else changed this row since
//     we read it. Drafts STAY — never silently discard the user's
//     typing. Row gets a "stale" badge with [Use mine] / [Reload]
//     in the page status bar. "Use mine" re-GETs the row to pick up
//     the new ETag and server data, replays drafts on top, re-PUTs
//     (this is the client-side field-level LWW trick from the
//     architecture report — fields the user didn't touch get the
//     server's new values automatically). "Reload" drops drafts and
//     refreshes from server.
//
//   - 422 Unprocessable Entity: server-side schema validation failed.
//     Body is {errors: [{path, message}, ...]}. Each path → field,
//     marked with a red corner on the cell. Drafts stay so the user
//     can correct in place.
//
//   - Other (4xx / 5xx / network): row marked errored with the
//     status code; drafts stay.
//
// Outbox transparency: when running through a downstream client, the
// PUT is intercepted by the cache layer; on local network failure
// it's queued and the response is 202 Accepted with X-ZDDC-Cache:
// queued. We treat 202 as success-ish — drafts clear, indicator
// shows a small "queued" badge so the user knows the write hasn't
// reached upstream yet.
(function (app) {
    'use strict';

    function modules() {
        return app.modules.editor;
    }

    function findRowById(rowId) {
        const all = (app.state && app.state.rows) || [];
        for (let i = 0; i < all.length; i++) {
            if (modules().rowKey(all[i]) === rowId) return all[i];
        }
        return null;
    }

    function mergeRow(data, drafts) {
        // Shallow merge: drafts are field-level overrides on the row's
        // top-level data object. Phase 2's complex-type cells punt to
        // form-mode and never produce drafts here, so drafts only
        // contain primitive / string-array values that are safe to
        // overwrite the corresponding top-level field.
        return Object.assign({}, data || {}, drafts || {});
    }

    function rowFromState(rowId) {
        return {
            row: findRowById(rowId),
            drafts: (app.state.drafts && app.state.drafts[rowId]) || null,
        };
    }

    // --- Visual state markers ----------------------------------------

    function setRowState(rowId, stateName) {
        // Apply a CSS state class to the row matching rowId. States:
        //   "" / null      — no marker
        //   "dirty"        — has uncommitted drafts
        //   "saving"       — PUT in flight
        //   "stale"        — server returned 412
        //   "errored"      — server returned 4xx/5xx other than 412/422
        //   "queued"       — write went into the outbox
        //   "invalid"      — server returned 422
        const tbody = document.querySelector('#table-root tbody');
        if (!tbody) return;
        const tr = tbody.querySelector('tr[data-row-id="' + cssEscape(rowId) + '"]');
        if (!tr) return;
        const stateClasses = ['dirty', 'saving', 'stale', 'errored', 'queued', 'invalid'];
        for (let i = 0; i < stateClasses.length; i++) {
            tr.classList.remove('zddc-table__row--' + stateClasses[i]);
        }
        if (stateName) tr.classList.add('zddc-table__row--' + stateName);
    }

    function markCellInvalid(rowId, field, message) {
        const tbody = document.querySelector('#table-root tbody');
        if (!tbody) return;
        const tr = tbody.querySelector('tr[data-row-id="' + cssEscape(rowId) + '"]');
        if (!tr) return;
        // Walk the column list to find the field's column index;
        // data-col-idx is the numeric position rendered into each td.
        const cols = (app.context && app.context.columns) || [];
        let idx = -1;
        for (let i = 0; i < cols.length; i++) {
            if (cols[i].field === field) { idx = i; break; }
        }
        if (idx < 0) return;
        const target = tr.querySelector('[role="gridcell"][data-col-idx="' + idx + '"]');
        if (!target) return;
        target.classList.add('zddc-table__cell--invalid');
        if (message) target.setAttribute('title', message);
    }

    function clearCellInvalid(rowId) {
        const tbody = document.querySelector('#table-root tbody');
        if (!tbody) return;
        const tr = tbody.querySelector('tr[data-row-id="' + cssEscape(rowId) + '"]');
        if (!tr) return;
        const invalids = tr.querySelectorAll('.zddc-table__cell--invalid');
        for (let i = 0; i < invalids.length; i++) {
            invalids[i].classList.remove('zddc-table__cell--invalid');
            invalids[i].removeAttribute('title');
        }
    }

    function cssEscape(s) {
        // CSS.escape if available; otherwise a defensive escape for
        // the characters that appear in URL paths used as data-row-id
        // values. Browsers everywhere modern enough to support the
        // FS Access API have CSS.escape, so this is mostly defensive.
        if (typeof CSS !== 'undefined' && CSS.escape) return CSS.escape(s);
        return String(s).replace(/[^a-zA-Z0-9_-]/g, function (ch) {
            return '\\' + ch;
        });
    }

    // --- Status bar (stale-row prompt) --------------------------------

    function showStatusPrompt(rowId, message, actions) {
        // Renders into #table-status (hidden by default per template).
        // actions = [{label, onClick}, ...]
        const el = document.getElementById('table-status');
        if (!el) return;
        el.textContent = '';
        el.classList.add('table-status--prompt');
        const span = document.createElement('span');
        span.textContent = message;
        el.appendChild(span);
        for (let i = 0; i < (actions || []).length; i++) {
            const a = actions[i];
            const btn = document.createElement('button');
            btn.type = 'button';
            btn.className = 'btn btn-secondary btn-sm';
            btn.textContent = a.label;
            btn.addEventListener('click', a.onClick);
            el.appendChild(btn);
        }
        const dismiss = document.createElement('button');
        dismiss.type = 'button';
        dismiss.className = 'btn btn-secondary btn-sm';
        dismiss.textContent = '×';
        dismiss.title = 'Dismiss';
        dismiss.addEventListener('click', clearStatus);
        el.appendChild(dismiss);
        el.hidden = false;
        el.setAttribute('data-row-id', rowId);
    }

    function clearStatus() {
        const el = document.getElementById('table-status');
        if (!el) return;
        el.textContent = '';
        el.hidden = true;
        el.removeAttribute('data-row-id');
        el.classList.remove('table-status--prompt');
    }

    // --- The save itself ---------------------------------------------

    async function saveRow(rowId, opts) {
        opts = opts || {};
        const { row, drafts } = rowFromState(rowId);
        if (!row || !drafts || Object.keys(drafts).length === 0) {
            return { status: 'noop' };
        }
        if (!row.yamlUrl) {
            // file:// mode or rows from inline-context test fixtures
            // don't have a URL to PUT to — bail silently.
            return { status: 'no-url' };
        }
        if (row.editable === false) {
            // Row is read-only per the server. Don't even try.
            return { status: 'readonly' };
        }

        setRowState(rowId, 'saving');
        const merged = mergeRow(row.data, drafts);
        const yamlBody = window.jsyaml.dump(merged);

        const headers = { 'Content-Type': 'application/yaml; charset=utf-8' };
        if (row.etag) headers['If-Match'] = '"' + row.etag + '"';

        const fetchOpts = {
            method: 'PUT',
            body: yamlBody,
            headers: headers,
            credentials: 'same-origin',
        };
        // The unload path passes keepalive:true so the PUT outlives the
        // page navigation. Subject to the spec's 64 KB body cap — large
        // rows may fail in that path; normal saves are unaffected.
        if (opts.keepalive) fetchOpts.keepalive = true;

        let resp;
        try {
            resp = await fetch(row.yamlUrl, fetchOpts);
        } catch (err) {
            // Network failure — outbox-fronted client should still
            // resolve with 202; reaching here means a hard client-side
            // network error. Mark errored, drafts stay.
            console.error('[tables] save network error', err);
            setRowState(rowId, 'errored');
            return { status: 'network-error', error: err };
        }

        if (resp.status === 200 || resp.status === 201) {
            // Success: clear drafts + invalid marks, capture new ETag.
            const newEtag = resp.headers.get('ETag');
            if (newEtag) row.etag = newEtag.replace(/"/g, '');
            row.data = merged;
            delete app.state.drafts[rowId];
            clearCellInvalid(rowId);
            setRowState(rowId, '');
            // If a status prompt was up for this row, drop it.
            const sb = document.getElementById('table-status');
            if (sb && sb.getAttribute('data-row-id') === rowId) clearStatus();
            return { status: 'ok' };
        }

        if (resp.status === 202) {
            // Outbox queued. Drafts clear (they're persisted in the
            // outbox; the server will replay them on reconnect), but
            // the row stays marked queued so the user knows.
            row.data = merged;
            delete app.state.drafts[rowId];
            setRowState(rowId, 'queued');
            return { status: 'queued' };
        }

        if (resp.status === 412) {
            // Precondition Failed — someone else changed the row.
            // Drafts STAY. Surface the prompt.
            setRowState(rowId, 'stale');
            showStatusPrompt(
                rowId,
                'This row was changed by someone else. ',
                [
                    { label: 'Use mine', onClick: () => useMine(rowId) },
                    { label: 'Reload',   onClick: () => reload(rowId) },
                ]
            );
            return { status: 'conflict' };
        }

        if (resp.status === 422) {
            // Validation errors. Body shape matches the form system's
            // 422 response: {errors: [{path: "/field", message}, ...]}.
            let body = {};
            try { body = await resp.json(); } catch (_) { /* ignore */ }
            clearCellInvalid(rowId);
            const errs = body.errors || [];
            for (let i = 0; i < errs.length; i++) {
                const e = errs[i];
                const field = String(e.path || '').replace(/^\//, '').split('/')[0];
                if (field) markCellInvalid(rowId, field, e.message || 'invalid');
            }
            setRowState(rowId, 'invalid');
            return { status: 'invalid', errors: errs };
        }

        // Other status — generic error.
        console.warn('[tables] save returned', resp.status);
        setRowState(rowId, 'errored');
        return { status: 'http-error', code: resp.status };
    }

    async function useMine(rowId) {
        const { row, drafts } = rowFromState(rowId);
        if (!row || !drafts) return;
        // Re-GET the row to learn the latest server state + ETag.
        try {
            const resp = await fetch(row.yamlUrl, { credentials: 'same-origin' });
            if (!resp.ok) {
                console.warn('[tables] reload on conflict failed', resp.status);
                return;
            }
            const text = await resp.text();
            const fresh = window.jsyaml.load(text) || {};
            row.data = fresh;
            const newEtag = resp.headers.get('ETag');
            row.etag = newEtag ? newEtag.replace(/"/g, '') : null;
        } catch (err) {
            console.error('[tables] reload on conflict error', err);
            return;
        }
        // Drafts preserved — replay against the new base.
        return saveRow(rowId);
    }

    async function reload(rowId) {
        const row = findRowById(rowId);
        if (!row) return;
        try {
            const resp = await fetch(row.yamlUrl, { credentials: 'same-origin' });
            if (!resp.ok) return;
            const text = await resp.text();
            row.data = window.jsyaml.load(text) || {};
            const newEtag = resp.headers.get('ETag');
            row.etag = newEtag ? newEtag.replace(/"/g, '') : null;
        } catch (_) { return; }
        delete app.state.drafts[rowId];
        clearCellInvalid(rowId);
        setRowState(rowId, '');
        clearStatus();
        // Trigger a re-paint via the public app callback if one exists.
        if (typeof app.repaint === 'function') app.repaint();
    }

    // --- Trigger: row-blur ------------------------------------------

    let _previousSelectedRowId = null;

    function trackSelectionChange(prevRowId, nextRowId) {
        // Fires when the editor's selection changes rows. If prevRow
        // had drafts, save it now. nextRow can be null (focus left
        // the grid) — also a save trigger.
        if (prevRowId && prevRowId !== nextRowId) {
            const drafts = app.state.drafts && app.state.drafts[prevRowId];
            if (drafts && Object.keys(drafts).length > 0) {
                // Fire and forget. The user has moved on; we don't
                // want to block their flow waiting for the server.
                saveRow(prevRowId).catch(err => {
                    console.error('[tables] saveRow rejection', err);
                });
            }
        }
    }

    function onSelectionChanged(selected) {
        const prevRowId = _previousSelectedRowId;
        const nextRowId = selected ? rowIdAtIndex(selected.row) : null;
        if (prevRowId !== nextRowId) {
            trackSelectionChange(prevRowId, nextRowId);
            _previousSelectedRowId = nextRowId;
        }
        // Mark dirty rows visually whenever selection settles.
        markAllDirtyRows();
    }

    function rowIdAtIndex(visibleRowIdx) {
        const tr = document.querySelectorAll('#table-root tbody > tr')[visibleRowIdx];
        return tr ? tr.getAttribute('data-row-id') : null;
    }

    function markAllDirtyRows() {
        // After a re-paint or selection change, re-apply dirty state
        // to any row that has drafts (CSS classes don't survive
        // tbody.innerHTML='' in renderBody).
        const drafts = app.state.drafts || {};
        const tbody = document.querySelector('#table-root tbody');
        if (!tbody) return;
        const trs = tbody.querySelectorAll('tr');
        for (let i = 0; i < trs.length; i++) {
            const tr = trs[i];
            const rowId = tr.getAttribute('data-row-id');
            if (rowId && drafts[rowId] && Object.keys(drafts[rowId]).length > 0) {
                if (!tr.classList.contains('zddc-table__row--saving') &&
                    !tr.classList.contains('zddc-table__row--stale') &&
                    !tr.classList.contains('zddc-table__row--invalid') &&
                    !tr.classList.contains('zddc-table__row--errored') &&
                    !tr.classList.contains('zddc-table__row--queued')) {
                    tr.classList.add('zddc-table__row--dirty');
                }
            }
        }
    }

    function flushAllDrafts() {
        // Page-unload safety net. Best-effort: any row with drafts
        // gets one final save attempt. fetch() is async, the page may
        // already be navigating; we just kick the requests off.
        const drafts = app.state.drafts || {};
        const ids = Object.keys(drafts);
        for (let i = 0; i < ids.length; i++) {
            saveRow(ids[i], { keepalive: true }).catch(() => {});
        }
    }

    // Window unload handler — call any in-flight drafts so the user
    // doesn't lose typing on tab-close. The PUT uses keepalive:true so
    // it survives navigation; that comes with a 64 KB body cap.
    window.addEventListener('beforeunload', function (_ev) {
        flushAllDrafts();
    });

    app.modules.save = {
        saveRow: saveRow,
        useMine: useMine,
        reload: reload,
        onSelectionChanged: onSelectionChanged,
        markAllDirtyRows: markAllDirtyRows,
        flushAllDrafts: flushAllDrafts,
    };
})(window.tablesApp);
