// preview-yaml.js — YAML editor plugin for the browse preview pane.
//
// Routes any .yaml / .yml file, plus the .zddc cascade files
// (`.zddc` and `*.zddc.yaml`), through a CodeMirror 5 editor with
// syntax highlighting and live linting. js-yaml.loadAll feeds parse
// errors into CM's lint gutter; for .zddc files an additional
// schema-aware pass flags unknown keys, bad enum values, and wrong
// types.
//
// Layout (single column):
//   ┌─────────────────────────────────────────────────────────────┐
//   │  name | dirty | status | source | [Save]                    │
//   ├─────────────────────────────────────────────────────────────┤
//   │  CodeMirror editor (line numbers + lint gutter)             │
//   └─────────────────────────────────────────────────────────────┘
//
// Save (Ctrl+S) writes back via PUT (server mode) or
// FileSystemWritableFileStream (FS-API). Zip members and
// virtual nodes are read-only — Save stays disabled.
(function () {
    'use strict';

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

    var util = window.app.modules.util;
    var escapeHtml = util.escapeHtml;

    // ── Filename routing ────────────────────────────────────────────────────

    // True for .zddc cascade files — `.zddc` (literal name, no ext)
    // and `<anything>.zddc.yaml` (e.g. `defaults.zddc.yaml`). These
    // get the schema-aware lint layer.
    function isZddcFile(name) {
        if (!name) return false;
        if (name === '.zddc') return true;
        return /\.zddc\.ya?ml$/i.test(name);
    }

    function isYamlFile(node) {
        if (!node || !node.name) return false;
        if (isZddcFile(node.name)) return true;
        var ext = (node.ext || '').toLowerCase();
        return ext === 'yaml' || ext === 'yml';
    }

    // ── Save (mirrors preview-markdown.js) ─────────────────────────────────

    function saveContent(node, content, opts) {
        // Via the shared saveFile so local (FS-Access) saves escalate to
        // readwrite the same as the markdown editor — previously this path
        // skipped ensureWritable and failed on read-only-picked folders.
        return util.saveFile(node, content, 'application/x-yaml; charset=utf-8', opts);
    }

    var isZipMemberNode = util.isZipMemberNode;

    function canSave(node) {
        if (isZipMemberNode(node)) return false;
        // Virtual .zddc placeholders are designed to be saved — a PUT
        // materializes the file from the synthetic body and the next
        // listing serves a real entry. Every other virtual node (per-
        // user home, canonical-folder virtuals) is just a tree
        // affordance, not a writable file.
        if (node.virtual && node.name !== '.zddc') return false;
        // Server-computed authority gate. The virtual .zddc entry
        // requires the admin verb 'a' (matches fileapi.go's
        // ActionAdmin gate at the .zddc URL); regular YAML files
        // require write 'w'. cap.has falls back to node.writable for
        // 'w' when verbs is absent (offline FS-API listings).
        if (node.url && window.app.state.source === 'server' && window.zddc.cap) {
            var needed = node.name === '.zddc' ? 'a' : 'w';
            if (!window.zddc.cap.has(node, needed)) return false;
        }
        if (node.handle && typeof node.handle.createWritable === 'function') return true;
        if (node.url && window.app.state.source === 'server') return true;
        return false;
    }

    var hashContent = util.hashContent;

    // ── .zddc schema ────────────────────────────────────────────────────────
    //
    // Mirrors the Go-side decoder in zddc/internal/zddc/*. Allowed
    // tool names are the embedded set (always available) plus the
    // composable ones served when declared in apps:. Unknown keys at
    // any level surface as warnings — typos like `defaul_tool` are
    // common and the cascade silently ignores them.

    var ALLOWED_TOOLS = {
        archive: 1, browse: 1, landing: 1, transmittal: 1, classifier: 1,
        tables: 1, form: 1
    };

    var TOP_KEYS = {
        title: 'string',
        acl: 'acl',
        admins: 'string[]',
        roles: 'rolemap',
        available_tools: 'tools[]',
        default_tool: 'tool',
        dir_tool: 'tool',
        auto_own: 'bool',
        auto_own_fenced: 'bool',
        virtual: 'bool',
        drop_target: 'bool',
        worm: 'string[]',
        paths: 'pathmap',
        display: 'stringmap',
        tables: 'stringmap',
        views: 'viewmap',
        convert: 'convert',
        created_by: 'string',
        inherit: 'bool'
    };

    var ACL_KEYS  = { inherit: 'bool', permissions: 'stringmap',
                      allow: 'string[]', deny: 'string[]' };
    var ROLE_KEYS = { members: 'string[]', reset: 'bool' };
    var CONVERT_KEYS = { client: 'string', project: 'string',
                         contractor: 'string', project_number: 'string' };

    function typeOf(v) {
        if (v === null || v === undefined) return 'null';
        if (Array.isArray(v)) return 'array';
        return typeof v;          // 'string' | 'number' | 'boolean' | 'object'
    }

    // Collect schema issues for a parsed .zddc document. Each issue is
    // { keyPath: string[], message: string, severity: 'error' | 'warning' }.
    // keyPath is used by findLine() to locate the offending source line.
    function validateZddc(doc) {
        var issues = [];
        if (typeOf(doc) === 'null') return issues;
        if (typeOf(doc) !== 'object') {
            issues.push({ keyPath: [], severity: 'error',
                message: 'Root must be a map (got ' + typeOf(doc) + ').' });
            return issues;
        }
        walkObject(doc, TOP_KEYS, [], issues);
        return issues;
    }

    function walkObject(obj, schema, path, issues) {
        for (var key in obj) {
            if (!Object.prototype.hasOwnProperty.call(obj, key)) continue;
            var here = path.concat([key]);
            var kind = schema[key];
            if (!kind) {
                issues.push({ keyPath: here, severity: 'warning',
                    message: 'Unknown key "' + key + '" — typo? It will be silently ignored.' });
                continue;
            }
            checkValue(obj[key], kind, here, issues);
        }
    }

    function checkValue(val, kind, path, issues) {
        var t = typeOf(val);
        switch (kind) {
            case 'string':
                if (t !== 'string' && t !== 'null') addTypeErr(path, kind, t, issues);
                return;
            case 'bool':
                if (t !== 'boolean' && t !== 'null') addTypeErr(path, kind, t, issues);
                return;
            case 'string[]':
                if (t !== 'array' && t !== 'null') addTypeErr(path, kind, t, issues);
                return;
            case 'tools[]':
                if (t !== 'array' && t !== 'null') {
                    addTypeErr(path, kind, t, issues); return;
                }
                if (t === 'array') {
                    for (var i = 0; i < val.length; i++) {
                        if (typeOf(val[i]) !== 'string') {
                            issues.push({ keyPath: path, severity: 'error',
                                message: 'available_tools[' + i + '] must be a string.' });
                        } else if (!ALLOWED_TOOLS[val[i]]) {
                            issues.push({ keyPath: path, severity: 'warning',
                                message: 'Unknown tool "' + val[i]
                                    + '". Known: ' + Object.keys(ALLOWED_TOOLS).join(', ') + '.' });
                        }
                    }
                }
                return;
            case 'tool':
                if (t === 'null') return;
                if (t !== 'string') { addTypeErr(path, kind, t, issues); return; }
                if (!ALLOWED_TOOLS[val]) {
                    issues.push({ keyPath: path, severity: 'warning',
                        message: 'Unknown tool "' + val + '". Known: '
                            + Object.keys(ALLOWED_TOOLS).join(', ') + '.' });
                }
                return;
            case 'stringmap':
                if (t === 'null') return;
                if (t !== 'object') { addTypeErr(path, kind, t, issues); return; }
                for (var k in val) {
                    if (!Object.prototype.hasOwnProperty.call(val, k)) continue;
                    if (typeOf(val[k]) !== 'string') {
                        issues.push({ keyPath: path.concat([k]), severity: 'error',
                            message: 'Value must be a string (got '
                                + typeOf(val[k]) + ').' });
                    }
                }
                return;
            case 'pathmap':
                if (t === 'null') return;
                if (t !== 'object') { addTypeErr(path, kind, t, issues); return; }
                for (var seg in val) {
                    if (!Object.prototype.hasOwnProperty.call(val, seg)) continue;
                    if (seg.indexOf('/') !== -1) {
                        issues.push({ keyPath: path.concat([seg]), severity: 'error',
                            message: 'Path keys must be a single segment — '
                                + 'nest blocks instead of using "' + seg + '".' });
                    }
                    var v = val[seg];
                    if (typeOf(v) === 'null') continue;
                    if (typeOf(v) !== 'object') {
                        issues.push({ keyPath: path.concat([seg]), severity: 'error',
                            message: 'paths.' + seg + ' must be a map of cascade rules.' });
                        continue;
                    }
                    walkObject(v, TOP_KEYS, path.concat([seg]), issues);
                }
                return;
            case 'viewmap':
                if (t === 'null') return;
                if (t !== 'object') { addTypeErr(path, kind, t, issues); return; }
                for (var shape in val) {
                    if (!Object.prototype.hasOwnProperty.call(val, shape)) continue;
                    if (['dir', 'dir_slash', 'file'].indexOf(shape) === -1) {
                        issues.push({ keyPath: path.concat([shape]), severity: 'warning',
                            message: 'Unknown view shape "' + shape + '" (known: dir, dir_slash, file).' });
                    }
                    var vv = val[shape];
                    if (typeOf(vv) !== 'object') {
                        issues.push({ keyPath: path.concat([shape]), severity: 'error',
                            message: 'views.' + shape + ' must be a map ({tool, config}).' });
                        continue;
                    }
                    if (typeOf(vv.tool) !== 'string' || !ALLOWED_TOOLS[vv.tool]) {
                        issues.push({ keyPath: path.concat([shape, 'tool']), severity: 'warning',
                            message: 'views.' + shape + '.tool should be a known tool ('
                                + Object.keys(ALLOWED_TOOLS).join(', ') + ').' });
                    }
                    if (vv.config !== undefined && typeOf(vv.config) !== 'string') {
                        issues.push({ keyPath: path.concat([shape, 'config']), severity: 'error',
                            message: 'views.' + shape + '.config must be a filename string.' });
                    }
                }
                return;
            case 'rolemap':
                if (t === 'null') return;
                if (t !== 'object') { addTypeErr(path, kind, t, issues); return; }
                for (var rn in val) {
                    if (!Object.prototype.hasOwnProperty.call(val, rn)) continue;
                    var rv = val[rn];
                    if (typeOf(rv) === 'null') continue;
                    if (typeOf(rv) !== 'object') {
                        issues.push({ keyPath: path.concat([rn]), severity: 'error',
                            message: 'roles.' + rn + ' must be a map ({members, reset}).' });
                        continue;
                    }
                    walkObject(rv, ROLE_KEYS, path.concat([rn]), issues);
                }
                return;
            case 'acl':
                if (t === 'null') return;
                if (t !== 'object') { addTypeErr(path, kind, t, issues); return; }
                walkObject(val, ACL_KEYS, path, issues);
                return;
            case 'convert':
                if (t === 'null') return;
                if (t !== 'object') { addTypeErr(path, kind, t, issues); return; }
                walkObject(val, CONVERT_KEYS, path, issues);
                return;
        }
    }

    function addTypeErr(path, expected, got, issues) {
        issues.push({ keyPath: path, severity: 'error',
            message: 'Expected ' + expected + ', got ' + got + '.' });
    }

    // Locate the source line for a key path. .zddc files are
    // block-style YAML in practice (no flow style, no anchors), so a
    // simple indent-aware scan works: for each segment, find a line
    // matching "<indent><key>:" whose indent is deeper than the
    // previously-matched line. Falls back to line 0 if no match.
    function findLine(source, keyPath) {
        if (!keyPath || keyPath.length === 0) return 0;
        var lines = source.split('\n');
        var prevIndent = -1;
        var prevLine = 0;
        for (var i = 0; i < keyPath.length; i++) {
            var key = keyPath[i];
            var found = -1;
            // Escape regex metachars in the key.
            var keyRe = key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
            var re = new RegExp('^(\\s*)"?' + keyRe + '"?\\s*:');
            for (var j = prevLine; j < lines.length; j++) {
                var m = lines[j].match(re);
                if (m && m[1].length > prevIndent) {
                    found = j;
                    prevIndent = m[1].length;
                    prevLine = j + 1;
                    break;
                }
            }
            if (found === -1) return prevLine > 0 ? prevLine - 1 : 0;
        }
        return prevLine > 0 ? prevLine - 1 : 0;
    }

    // ── CodeMirror lint helper ──────────────────────────────────────────────

    function registerLinter(CM) {
        // The lint helper signature: function(text, options, editor) → annotations[]
        // Each annotation: { from, to, message, severity }.
        CM.registerHelper('lint', 'yaml', function (text, _opts, editor) {
            var out = [];
            if (!window.jsyaml) return out;
            var parsed;
            try {
                // loadAll handles multi-doc YAML; we only validate the
                // first doc against the schema (the .zddc cascade reads
                // only the first document).
                var docs = [];
                window.jsyaml.loadAll(text, function (d) { docs.push(d); });
                parsed = docs[0];
            } catch (e) {
                var mark = e.mark;
                var pos = mark ? CM.Pos(mark.line, mark.column) : CM.Pos(0, 0);
                out.push({ from: pos, to: pos, severity: 'error',
                    message: e.message || String(e) });
                return out;
            }
            // Schema layer — only for .zddc cascade files.
            var node = editor._zddcNode;
            if (node && isZddcFile(node.name)) {
                var issues = validateZddc(parsed);
                for (var i = 0; i < issues.length; i++) {
                    var ln = findLine(text, issues[i].keyPath);
                    out.push({
                        from: CM.Pos(ln, 0),
                        to:   CM.Pos(ln, (editor.getLine(ln) || '').length),
                        severity: issues[i].severity,
                        message: issues[i].message
                    });
                }
            }
            return out;
        });
    }

    // ── Mount ───────────────────────────────────────────────────────────────

    var currentEditor = null;
    var currentDirty = false;
    var currentNodeRef = null;
    // Server version token for the loaded file — sent as If-Match on save
    // and refreshed from each successful PUT's response ETag.
    var currentEtag = null;
    var currentLastModified = null;

    function dispose() {
        // CM doesn't have an explicit destroy(); GC handles it once
        // the host element is removed. Clear our reference so a stale
        // editor doesn't keep handlers alive.
        currentEditor = null;
        currentDirty = false;
        currentNodeRef = null;
        currentEtag = null;
        currentLastModified = null;
    }

    function isDirty() {
        return currentDirty;
    }

    function currentNode() {
        return currentNodeRef;
    }

    async function render(node, container, ctx) {
        if (typeof window.CodeMirror === 'undefined') {
            container.innerHTML =
                '<div class="preview-empty" style="color:var(--danger)">'
                + 'CodeMirror isn\'t bundled in this build.</div>';
            return;
        }
        dispose();

        var text, loadedEtag = null, loadedLastModified = null;
        try {
            if (ctx.getContentWithVersion) {
                var loaded = await ctx.getContentWithVersion(node);
                text = new TextDecoder('utf-8', { fatal: false }).decode(loaded.buf);
                loadedEtag = loaded.etag;
                loadedLastModified = loaded.lastModified;
            } else {
                var buf = await ctx.getArrayBuffer(node);
                text = new TextDecoder('utf-8', { fatal: false }).decode(buf);
            }
        } catch (e) {
            container.innerHTML =
                '<div class="preview-empty" style="color:var(--danger)">'
                + 'Could not read ' + escapeHtml(node.name) + ': '
                + escapeHtml(e.message || String(e)) + '</div>';
            return;
        }

        container.innerHTML = '';
        var shell = document.createElement('div');
        shell.className = 'yaml-shell';
        container.appendChild(shell);

        // Info header — same look as the markdown plugin's info-header
        // so the two editors feel like one family.
        var infohdr = document.createElement('div');
        infohdr.className = 'md-shell__infohdr yaml-shell__infohdr';

        var titleEl = document.createElement('span');
        titleEl.className = 'md-shell__title';
        titleEl.textContent = node.name;
        titleEl.title = node.name;

        var schemaTag = document.createElement('span');
        schemaTag.className = 'md-shell__source yaml-shell__schema';
        if (isZddcFile(node.name)) {
            schemaTag.textContent = '.zddc schema';
            schemaTag.title = 'Linted against the .zddc cascade schema '
                + '(unknown keys, bad enums, and wrong types are flagged).';
        } else {
            schemaTag.textContent = 'YAML';
        }

        var dirtyEl = document.createElement('span');
        dirtyEl.className = 'md-shell__dirty';

        var statusEl = document.createElement('span');
        statusEl.className = 'md-shell__status';

        var sourceEl = document.createElement('span');
        sourceEl.className = 'md-shell__source';
        if (isZipMemberNode(node)) sourceEl.textContent = 'read-only (zip)';
        else if (node.handle) sourceEl.textContent = 'local';
        else if (node.url) sourceEl.textContent = 'server';

        var saveBtn = document.createElement('button');
        saveBtn.className = 'btn btn-sm btn-primary md-shell__save';
        saveBtn.type = 'button';
        saveBtn.textContent = 'Save';
        saveBtn.disabled = true;

        infohdr.appendChild(titleEl);
        infohdr.appendChild(schemaTag);
        infohdr.appendChild(dirtyEl);
        infohdr.appendChild(statusEl);
        infohdr.appendChild(sourceEl);
        infohdr.appendChild(saveBtn);
        shell.appendChild(infohdr);

        var editorHost = document.createElement('div');
        editorHost.className = 'yaml-shell__editor';
        shell.appendChild(editorHost);

        // Register the lint helper once per page lifetime.
        if (!window.CodeMirror.__zddcYamlLinterReady) {
            registerLinter(window.CodeMirror);
            window.CodeMirror.__zddcYamlLinterReady = true;
        }

        var writable = canSave(node);
        var editor = window.CodeMirror(editorHost, {
            value: text,
            mode: 'yaml',
            lineNumbers: true,
            tabSize: 2,
            indentUnit: 2,
            indentWithTabs: false,
            lineWrapping: false,
            gutters: ['CodeMirror-lint-markers', 'CodeMirror-linenumbers'],
            lint: { hasGutters: true },
            // autofocus:false keeps the keyboard caret in the browse
            // tree pane so arrow-key nav can continue through yaml /
            // .zddc files without diverting into the editor. User
            // clicks (or tabs) into the editor when they want to type.
            autofocus: false,
            // Read-only uses readOnly:true (NOT "nocursor"): the editor
            // stays focusable so the user can click in, select text, and
            // copy — they just can't edit. "nocursor" removes the textarea
            // from focus, which also kills click-drag selection (the whole
            // reason a viewer would otherwise force admin mode just to copy
            // a .zddc snippet). autofocus:false keeps arrow-key tree nav
            // intact until the user deliberately clicks into the editor.
            readOnly: !writable,
        });
        // Stash the node on the editor so the lint helper can decide
        // whether to apply the .zddc schema layer.
        editor._zddcNode = node;
        // Force an initial lint pass now that _zddcNode is set.
        editor.performLint();
        currentEditor = editor;
        currentNodeRef = node;
        currentDirty = false;
        currentEtag = loadedEtag;
        currentLastModified = loadedLastModified;

        if (!writable) {
            saveBtn.disabled = true;
            saveBtn.title = 'Save not available — read-only source.';
            // Read-only banner above the editor explains why.
            var roBanner = document.createElement('div');
            roBanner.className = 'yaml-readonly-banner';
            roBanner.innerHTML = '<span aria-hidden="true">🔒</span>'
                + ' Read-only — you don\'t have write access to this file.';
            editorHost.insertBefore(roBanner, editorHost.firstChild);
        }

        var initialHash = await hashContent(text);

        function markDirty(isDirty) {
            if (currentEditor !== editor) return; // editor replaced
            currentDirty = isDirty;
            saveBtn.disabled = !isDirty || !canSave(node);
            dirtyEl.textContent = isDirty ? '● modified' : '';
        }

        editor.on('change', async function () {
            if (currentEditor !== editor) return; // switched away
            var h = await hashContent(editor.getValue());
            if (currentEditor !== editor) return; // replaced during await
            markDirty(h !== initialHash);
        });

        // Adopt the new server ETag + refresh the dirty baseline after a
        // successful write so save→edit→save doesn't false-conflict.
        async function markSaved(content, res) {
            if (currentEditor !== editor) return;
            if (res && res.etag) currentEtag = res.etag;
            initialHash = await hashContent(content);
            if (currentEditor !== editor) return;
            markDirty(false);
            statusEl.textContent = 'Saved ' + new Date().toLocaleTimeString();
            if (window.zddc && window.zddc.toast) {
                window.zddc.toast('Saved ' + node.name, 'success');
            }
        }

        // 412 → file changed on the server since load. Open the shared
        // conflict dialog instead of clobbering.
        async function resolveConflict(content) {
            var conflict = window.app.modules.conflict;
            var prev = window.app.modules.preview;
            if (!conflict || !prev) return;
            await conflict.open({
                filename: node.name,
                mineText: content,
                fetchTheirs: function () {
                    return prev.getContentWithVersion(node).then(function (r) {
                        return new TextDecoder('utf-8', { fatal: false }).decode(r.buf);
                    });
                },
                onOverwrite: function () {
                    return prev.getContentWithVersion(node).then(function (cur) {
                        return saveContent(node, content, { etag: cur.etag, lastModified: cur.lastModified });
                    }).then(function (res) { return markSaved(content, res); });
                },
                onReload: function () {
                    markDirty(false);
                    currentDirty = false;
                    return prev.showFilePreview(node);
                },
                onSaveCopy: function () {
                    return util.saveCopy(node, content, 'application/x-yaml; charset=utf-8')
                        .then(function (name) {
                            if (window.zddc && window.zddc.toast) {
                                window.zddc.toast('Saved your version as ' + name, 'success');
                            }
                        });
                }
            });
            if (currentEditor === editor) statusEl.textContent = '';
        }

        async function save() {
            if (saveBtn.disabled) return;
            // Re-check authority at click time, not via the mount-time
            // `writable` capture — the listing may have re-evaluated
            // (e.g. user toggled admin mode without a hard reload).
            if (!canSave(node)) return;
            var content = editor.getValue();
            try {
                statusEl.textContent = 'Saving…';
                var res = await saveContent(node, content, {
                    etag: currentEtag, lastModified: currentLastModified
                });
                await markSaved(content, res);
            } catch (e) {
                if (e && e.status === 412) {
                    if (currentEditor !== editor) return;
                    statusEl.textContent = 'Conflict — resolving…';
                    await resolveConflict(content);
                    return;
                }
                statusEl.textContent = 'Save failed: ' + (e.message || e);
                if (window.zddc && window.zddc.toast) {
                    window.zddc.toast('Save failed: ' + (e.message || e), 'error');
                }
            }
        }
        saveBtn.addEventListener('click', save);
        editor.setOption('extraKeys', {
            'Ctrl-S': save,
            'Cmd-S':  save
        });

        // CM defers layout until its host has a size — refresh after
        // mount so the gutters and viewport sync to the grid cell.
        setTimeout(function () { try { editor.refresh(); } catch (_e) {} }, 0);
    }

    function handles(node) {
        if (!node || node.isDir || node.isZip) return false;
        return isYamlFile(node);
    }

    window.app.modules.yamledit = {
        handles: handles,
        render: render,
        dispose: dispose,
        isDirty: isDirty,
        currentNode: currentNode
    };
})();
