// util.js — small browse-local helpers shared across the tool's modules.
//
// Consolidates copies that had drifted across modules: escapeHtml (some
// variants escaped single-quotes and handled null, others didn't), the
// SHA-256 content hasher (byte-identical in both editors), ISO-date and
// YAML-quote helpers (duplicated across the workflow modals), the
// /.profile/access email lookup, byte-size formatting, and the editor
// save/zip-member primitives. Attaches to window.app.modules.util — no new
// global (per the two-globals rule). Concatenated right after init.js so
// it's present when every later module's IIFE runs.
(function () {
    'use strict';

    if (!window.app || !window.app.modules) return;

    // Escape a value for HTML text/attribute insertion. Escapes all five
    // significant characters (including the single quote, which some call
    // sites need for single-quoted attributes) and treats null/undefined
    // as an empty string. Strict superset of every previous local copy.
    function escapeHtml(s) {
        return String(s == null ? '' : s).replace(/[&<>"']/g, function (c) {
            return ({
                '&': '&amp;', '<': '&lt;', '>': '&gt;',
                '"': '&quot;', "'": '&#39;'
            })[c];
        });
    }

    // SHA-256 hex of a string, or null when WebCrypto is unavailable.
    // Used to gate editor dirty-state.
    async function hashContent(text) {
        if (!window.crypto || !window.crypto.subtle) return null;
        var enc = new TextEncoder().encode(text);
        var buf = await window.crypto.subtle.digest('SHA-256', enc);
        var bytes = new Uint8Array(buf);
        var hex = '';
        for (var i = 0; i < bytes.length; i++) {
            hex += bytes[i].toString(16).padStart(2, '0');
        }
        return hex;
    }

    function pad2(n) { return ('0' + n).slice(-2); }
    function fmtIsoDate(d) {
        return d.getFullYear() + '-' + pad2(d.getMonth() + 1) + '-' + pad2(d.getDate());
    }
    // YYYY-MM-DD for today / today + N days (local time).
    function isoDateToday() { return fmtIsoDate(new Date()); }
    function isoDatePlus(days) {
        var d = new Date();
        d.setDate(d.getDate() + days);
        return fmtIsoDate(d);
    }

    // Double-quoted YAML scalar with backslash + quote escaping. Enough for
    // the email/string fields the workflow modals emit.
    function yamlQuote(s) {
        return '"' + String(s).replace(/\\/g, '\\\\').replace(/"/g, '\\"') + '"';
    }

    // GET /.profile/access → [email] for datalist suggestions. Best-effort:
    // returns [] on any error so callers can populate a datalist blind.
    async function fetchAccessEmails() {
        try {
            var r = await fetch('/.profile/access', {
                headers: { 'Accept': 'application/json' },
                credentials: 'same-origin'
            });
            if (!r.ok) return [];
            var d = await r.json();
            return (d && d.email) ? [d.email] : [];
        } catch (_e) { return []; }
    }

    function fmtSize(bytes) {
        if (bytes == null) return '';
        if (bytes < 1024) return bytes + ' B';
        if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
        if (bytes < 1024 * 1024 * 1024) return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
        return (bytes / (1024 * 1024 * 1024)).toFixed(2) + ' GB';
    }

    // A file living inside a .zip is read-only: a ZipFileHandle refuses
    // createWritable (offline / nested) and zddc-server refuses writes to a
    // "<…>.zip/<member>" URL (405).
    function isZipMemberNode(node) {
        if (node.handle && node.handle.isZipEntry) return true;
        if (node.url && window.app.state.source === 'server'
            && /\.zip\//i.test(node.url)) return true;
        return false;
    }

    // Thrown by saveFile when the server rejects a write with 412
    // Precondition Failed — the file changed under us since we loaded it.
    // Callers branch on `.status === 412` to open the conflict UI instead
    // of treating it as a generic save failure.
    function ConflictError(message) {
        var e = new Error(message || 'Conflict: file changed on server');
        e.name = 'ConflictError';
        e.status = 412;
        return e;
    }

    // Write content back to a file's source, returning { etag } (the new
    // server ETag, or null in FS-Access mode). Local (FS-Access) folders are
    // picked read-only, so the first write escalates to readwrite via
    // upload.ensureWritable (one permission prompt, then granted for the
    // session). contentType sets the PUT Content-Type for server files.
    //
    // opts (server mode only):
    //   etag         — send as `If-Match` so the master 412s if the file
    //                  changed since we observed this version (optimistic
    //                  concurrency; preferred — exact).
    //   lastModified — fallback precondition sent as `If-Unmodified-Since`
    //                  (raw HTTP-date string) when no etag is available.
    //   force        — skip the precondition entirely (deliberate overwrite).
    //
    // Throws ConflictError (.status===412) on a precondition failure, a
    // plain Error('HTTP <status>') on any other non-2xx, or "no write
    // target" when the source is read-only.
    async function saveFile(node, content, contentType, opts) {
        opts = opts || {};
        if (node.handle && typeof node.handle.createWritable === 'function') {
            var up = window.app.modules.upload;
            if (up && up.ensureWritable) await up.ensureWritable();
            var writable = await node.handle.createWritable();
            await writable.write(content);
            await writable.close();
            return { etag: null };
        }
        if (node.url && window.app.state.source === 'server') {
            var headers = { 'Content-Type': contentType };
            if (!opts.force) {
                if (opts.etag) headers['If-Match'] = opts.etag;
                else if (opts.lastModified) headers['If-Unmodified-Since'] = opts.lastModified;
            }
            var resp = await fetch(node.url, {
                method: 'PUT',
                headers: headers,
                body: content,
                credentials: 'same-origin'
            });
            if (resp.status === 412) throw ConflictError();
            if (!resp.ok) throw new Error('HTTP ' + resp.status);
            return { etag: resp.headers.get('ETag') || null };
        }
        throw new Error('No write target for this file (read-only source).');
    }

    // Write `content` to a NEW sibling of `node` named
    // `<stem>-conflict-<YYYYMMDD-HHMMSS>.<ext>` (server mode only), so a
    // conflicting edit can be parked without losing either version. Probes
    // for a free name (numeric-suffix bump, capped) so a same-second retry
    // doesn't clobber a prior copy. Returns the created filename. The PUT
    // uses no precondition — it's a brand-new path.
    async function saveCopy(node, content, contentType) {
        if (!(node.url && window.app.state.source === 'server')) {
            throw new Error('Save a copy is only available for server files.');
        }
        var split = window.zddc.splitExtension(node.name);
        var stem = split.name || node.name;
        var ext = split.extension;
        var d = new Date();
        var stamp = d.getFullYear() + pad2(d.getMonth() + 1) + pad2(d.getDate())
            + '-' + pad2(d.getHours()) + pad2(d.getMinutes()) + pad2(d.getSeconds());
        var base = stem + '-conflict-' + stamp;
        var slash = node.url.lastIndexOf('/');
        var dirUrl = slash >= 0 ? node.url.slice(0, slash + 1) : '';
        var name = '', candidateUrl = '';
        for (var i = 0; i < 20; i++) {
            name = window.zddc.joinExtension(base + (i ? '-' + (i + 1) : ''), ext);
            candidateUrl = dirUrl + encodeURIComponent(name);
            var head;
            try {
                head = await fetch(candidateUrl, { method: 'HEAD', credentials: 'same-origin' });
            } catch (_e) {
                break; // network unknown — attempt the write rather than spin
            }
            if (head.status === 404) break;        // free slot
            if (head.status !== 200) break;         // HEAD unsupported / odd — attempt anyway
            if (i === 19) throw new Error('Could not find a free filename for the copy.');
        }
        await saveFile({ url: candidateUrl, name: name, ext: ext }, content, contentType, { force: true });
        return name;
    }

    window.app.modules.util = {
        escapeHtml: escapeHtml,
        hashContent: hashContent,
        isoDateToday: isoDateToday,
        isoDatePlus: isoDatePlus,
        yamlQuote: yamlQuote,
        fetchAccessEmails: fetchAccessEmails,
        fmtSize: fmtSize,
        isZipMemberNode: isZipMemberNode,
        saveFile: saveFile,
        saveCopy: saveCopy,
        ConflictError: ConflictError
    };
})();
