// 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.
        //
        // $-prefixed keys are system-synthesised on read (e.g. `$party`,
        // injected by the server on mdl/rsk rows or derived from the
        // party subdir in the aggregate view). They are not part of the
        // row's stored YAML and would be rejected by the schema's
        // additionalProperties rule. Strip them before sending the write.
        const merged = Object.assign({}, data || {}, drafts || {});
        for (const k of Object.keys(merged)) {
            if (k.charAt(0) === '$') delete merged[k];
        }
        return merged;
    }

    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) return { status: 'noop' };
        const hasDrafts = drafts && Object.keys(drafts).length > 0;
        // New (unsaved) rows: if the user added a row and then moved on
        // without typing anything, drop the empty placeholder rather
        // than POST an empty body that fails schema validation.
        if (row.isNew && !hasDrafts) {
            const addRow = app.modules.addRow;
            if (addRow && typeof addRow.discardEmpty === 'function') {
                addRow.discardEmpty(rowId);
            }
            return { status: 'discarded-empty' };
        }
        if (!hasDrafts) return { status: 'noop' };
        if (row.isNew) {
            return createRow(rowId, row, drafts, opts);
        }
        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, '');
            // For record-typed writes the server echoes the stamped
            // YAML (with server-managed audit fields) back as the
            // response body — parse it and overwrite row.data so the
            // table sees the same bytes that just landed on disk.
            // Falls back to the local merge when the server didn't
            // echo a body (non-record write or older server).
            let serverData = null;
            const ct = (resp.headers.get('Content-Type') || '').toLowerCase();
            if (ct.includes('yaml') && window.jsyaml) {
                try {
                    const text = await resp.text();
                    if (text && text.trim()) serverData = window.jsyaml.load(text);
                } catch (e) {
                    console.warn('[tables] server response YAML parse failed; using local merge', e);
                }
            }
            row.data = serverData || 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();
            updateSaveButton();
            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');
            updateSaveButton();
            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 };
        }

        if (resp.status === 403) {
            setRowState(rowId, 'errored');
            if (window.zddc && window.zddc.cap) {
                window.zddc.cap.handleForbidden(resp, {
                    context: 'Save row',
                    path: location.pathname
                });
            }
            return { status: 'forbidden' };
        }

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

    // createRow handles the POST path for an isNew row. Body is YAML of
    // the row's draft data (no row.data yet — it's a fresh row). Success
    // is 201 + Location pointing at the new <id>.yaml; we swap the
    // synthetic url/yamlUrl for the real ones and clear isNew so the
    // row behaves like any other from this point on.
    async function createRow(rowId, row, drafts, opts) {
        const addRow = app.modules.addRow;
        if (!addRow || typeof addRow.formCreateUrl !== 'function') {
            setRowState(rowId, 'errored');
            return { status: 'no-create-url' };
        }
        const createUrl = addRow.formCreateUrl();
        const merged = mergeRow(row.data, drafts);
        const yamlBody = window.jsyaml.dump(merged);

        const headers = { 'Content-Type': 'application/yaml; charset=utf-8' };
        const fetchOpts = {
            method: 'POST',
            body: yamlBody,
            headers: headers,
            credentials: 'same-origin',
        };
        if (opts && opts.keepalive) fetchOpts.keepalive = true;

        setRowState(rowId, 'saving');
        let resp;
        try {
            resp = await fetch(createUrl, fetchOpts);
        } catch (err) {
            console.error('[tables] createRow network error', err);
            setRowState(rowId, 'errored');
            return { status: 'network-error', error: err };
        }

        if (resp.status === 201) {
            // Server wrote the row. Body is {location, filename}; we
            // also accept the Location header if the body isn't JSON.
            let body = {};
            try { body = await resp.json(); } catch (_) { /* ignore */ }
            const location = body.location || resp.headers.get('Location') || '';
            const newEtag = (resp.headers.get('ETag') || '').replace(/"/g, '');
            row.yamlUrl = location;
            row.url = location ? location + '.html' : row.url;
            // Re-fetch the just-written row so server-derived fields
            // surface immediately: folder-bound originator, the composed
            // tracking number's components, and audit stamps. The local
            // `merged` lacks these (e.g. originator is read-only and
            // never typed). Fall back to merged if the GET fails.
            row.data = merged;
            if (location) {
                try {
                    const back = await fetch(location, { credentials: 'same-origin' });
                    if (back.ok) {
                        const text = await back.text();
                        if (text && text.trim() && window.jsyaml) {
                            row.data = window.jsyaml.load(text) || merged;
                        }
                        const fetchedEtag = (back.headers.get('ETag') || '').replace(/"/g, '');
                        if (fetchedEtag) row.etag = fetchedEtag;
                    }
                } catch (e) {
                    console.warn('[tables] post-create re-fetch failed; using local merge', e);
                }
            }
            if (!row.etag) row.etag = newEtag || null;
            row.isNew = false;
            // Move the drafts entry (was keyed on the synthetic id) to
            // the new url, then clear it (data has the merged values).
            delete app.state.drafts[rowId];
            clearCellInvalid(rowId);
            setRowState(rowId, '');
            const sb = document.getElementById('table-status');
            if (sb && sb.getAttribute('data-row-id') === rowId) clearStatus();
            // Re-paint so the row picks up its new data-row-id and any
            // server-supplied default fields surface.
            if (typeof app.repaint === 'function') app.repaint();
            return { status: 'ok' };
        }

        if (resp.status === 422) {
            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 };
        }

        if (resp.status === 403) {
            setRowState(rowId, 'errored');
            if (window.zddc && window.zddc.cap) {
                window.zddc.cap.handleForbidden(resp, {
                    context: 'Add row',
                    path: location.pathname
                });
            }
            return { status: 'forbidden' };
        }

        if (resp.status === 409) {
            // The composed tracking number collides with an existing
            // row (the server rejects duplicates). Surface it on the
            // sequence cell — the usual disambiguator — rather than the
            // generic errored state, so the user knows to bump a
            // component instead of retrying the same values.
            let msg = 'Duplicate tracking number — change a component (e.g. sequence).';
            try { const t = await resp.text(); if (t && t.trim()) msg = t.trim(); } catch (_) { /* ignore */ }
            clearCellInvalid(rowId);
            markCellInvalid(rowId, 'sequence', msg);
            setRowState(rowId, 'invalid');
            return { status: 'duplicate', message: msg };
        }

        console.warn('[tables] createRow 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(() => {});
        }
    }

    // flushAll fires saves for every dirty row and returns when they
    // all settle. Used by the explicit Save button and the auto-save
    // when focus leaves the grid. Unlike flushAllDrafts, this is NOT
    // keepalive — the page isn't going anywhere, so we wait for real
    // responses and surface errors normally.
    async function flushAll() {
        const drafts = app.state.drafts || {};
        const ids = Object.keys(drafts).filter(id => drafts[id] && Object.keys(drafts[id]).length > 0);
        if (ids.length === 0) return { status: 'noop' };
        const results = await Promise.allSettled(ids.map(id => saveRow(id)));
        const ok = results.filter(r => r.status === 'fulfilled' && r.value && r.value.status === 'ok').length;
        return { status: 'done', total: ids.length, ok: ok, failed: ids.length - ok };
    }

    // Count rows that have at least one unsaved field.
    function dirtyCount() {
        const drafts = app.state.drafts || {};
        let n = 0;
        for (const id in drafts) {
            if (drafts[id] && Object.keys(drafts[id]).length > 0) n++;
        }
        return n;
    }

    // Update the toolbar Save button visibility + label from current
    // draft state. Called from editor.js whenever drafts mutate; also
    // safe to call anytime (e.g. after a paint).
    function updateSaveButton() {
        const btn = document.getElementById('table-save');
        if (!btn) return;
        const n = dirtyCount();
        if (n === 0) {
            btn.hidden = true;
            btn.textContent = 'Save';
            return;
        }
        btn.hidden = false;
        btn.textContent = n === 1 ? 'Save (1 unsaved)' : 'Save (' + n + ' unsaved)';
    }

    function onDraftsChanged() {
        updateSaveButton();
        markAllDirtyRows();
    }

    // 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,
        onDraftsChanged: onDraftsChanged,
        markAllDirtyRows: markAllDirtyRows,
        updateSaveButton: updateSaveButton,
        flushAll: flushAll,
        dirtyCount: dirtyCount,
        flushAllDrafts: flushAllDrafts,
    };
})(window.tablesApp);
