// 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';
    }

    // The CodeMirror editor is the general editor for editable TEXT files that
    // aren't markdown (markdown has its own editor). Syntax highlighting is
    // YAML-only — that's the one CM mode in the vendored bundle — so every
    // other type opens as a plaintext editor (still line numbers, find,
    // selection, save). svg/json-as-image etc. stay with their preview
    // renderers; this set is deliberately the "edit the source" types.
    var CODE_EXTS = {
        yaml: 1, yml: 1, txt: 1, text: 1, csv: 1, tsv: 1, tab: 1,
        json: 1, xml: 1, html: 1, htm: 1, css: 1, js: 1, mjs: 1,
        log: 1, ini: 1, conf: 1, cfg: 1, toml: 1, env: 1,
        sh: 1, bash: 1, properties: 1
    };
    function isCodeFile(node) {
        if (!node || node.isDir || node.isZip) return false;
        if (isYamlFile(node)) return true;
        return !!CODE_EXTS[(node.ext || '').toLowerCase()];
    }
    // CodeMirror mode by extension — only yaml is vendored; others plaintext.
    function codeMode(node) { return isYamlFile(node) ? 'yaml' : null; }

    // ── 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;
    var isEditableZipMember = util.isEditableZipMember;

    function canSave(node) {
        // A .zddc.zip bundle member is saveable iff editable (elevated admin);
        // the server's ServeZipWrite is the real gate. Other zip members are
        // read-only.
        if (isZipMemberNode(node)) return isEditableZipMember(node);
        // 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.

    // The valid keys, types, enums and nesting are NOT hand-listed here any
    // more — they come from the baked .zddc JSON Schema (window.__ZDDC_SCHEMA__,
    // the same grammar the server serves at /.api/zddc-schema and that drives
    // completion + hover). One source, no drift. See validateZddcSchema below.

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

    // The .zddc JSON Schema, baked into the bundle at build time
    // (window.__ZDDC_SCHEMA__ — the same file the server serves at
    // /.api/zddc-schema). Single source for lint, completion and hover; works
    // offline. Synchronous, so the lint helper can use it directly.
    function getZddcSchema() {
        return (window.__ZDDC_SCHEMA__ && window.__ZDDC_SCHEMA__.properties)
            ? window.__ZDDC_SCHEMA__ : {};
    }

    // Validate a parsed .zddc document against the JSON Schema, producing
    // { keyPath, severity, message } issues (mapped to source lines by
    // findLine). Covers the draft-2020-12 subset .zddc uses: type, enum,
    // properties, additionalProperties (false | schema), patternProperties,
    // items, pattern, and the recursive $ref:"#" (paths:).
    function validateZddc(doc) {
        var schema = getZddcSchema();
        var issues = [];
        if (!schema || !schema.properties) return issues; // schema unavailable
        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;
        }
        function deref(n) { return (n && n.$ref === '#') ? schema : n; }
        function typeOk(t, want) {
            if (Array.isArray(want)) {
                for (var i = 0; i < want.length; i++) if (typeOk(t, want[i])) return true;
                return false;
            }
            if (want === 'integer' || want === 'number') return t === 'number';
            return t === want;
        }
        function walk(value, sch, path) {
            sch = deref(sch);
            if (!sch) return;
            var t = typeOf(value);
            if (t === 'null') return; // empty value mid-edit — don't flag
            if (sch.type && !typeOk(t, sch.type)) {
                issues.push({ keyPath: path, severity: 'error',
                    message: 'Expected ' + (Array.isArray(sch.type) ? sch.type.join('/') : sch.type)
                        + ', got ' + t + '.' });
                return;
            }
            if (sch.enum && sch.enum.map(String).indexOf(String(value)) === -1) {
                issues.push({ keyPath: path, severity: 'warning',
                    message: 'Unknown value "' + value + '". Allowed: ' + sch.enum.join(', ') + '.' });
            }
            if (sch.pattern && t === 'string' && !new RegExp(sch.pattern).test(value)) {
                issues.push({ keyPath: path, severity: 'error',
                    message: 'Value "' + value + '" must match ' + sch.pattern + '.' });
            }
            if (t === 'object') {
                var props = sch.properties || {};
                for (var k in value) {
                    if (!Object.prototype.hasOwnProperty.call(value, k)) continue;
                    var kp = path.concat([k]);
                    if (props[k]) { walk(value[k], props[k], kp); continue; }
                    var ap = sch.additionalProperties;
                    if (ap && typeof ap === 'object') { walk(value[k], ap, kp); continue; }
                    if (sch.patternProperties) {
                        var matched = null;
                        for (var p in sch.patternProperties) {
                            if (Object.prototype.hasOwnProperty.call(sch.patternProperties, p)
                                && new RegExp(p).test(k)) { matched = sch.patternProperties[p]; break; }
                        }
                        if (matched) { walk(value[k], matched, kp); continue; }
                    }
                    if (ap === false) {
                        issues.push({ keyPath: kp, severity: 'warning',
                            message: 'Unknown key "' + k + '" — not in the .zddc schema; it will be ignored.' });
                    }
                }
            } else if (t === 'array' && sch.items) {
                for (var i = 0; i < value.length; i++) {
                    walk(value[i], sch.items, path.concat([String(i)]));
                }
            }
        }
        walk(doc, schema, []);
        return issues;
    }

    // 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). '
                + 'Click to view the full JSON Schema.';
            // Clickable → opens the canonical machine grammar the lint mirrors.
            schemaTag.classList.add('yaml-shell__schema--link');
            schemaTag.setAttribute('role', 'link');
            schemaTag.setAttribute('tabindex', '0');
            var openSchema = function () {
                window.open('/.api/zddc-schema', '_blank', 'noopener');
            };
            schemaTag.addEventListener('click', openSchema);
            schemaTag.addEventListener('keydown', function (ev) {
                if (ev.key === 'Enter' || ev.key === ' ') { ev.preventDefault(); openSchema(); }
            });
        } else if (isYamlFile(node)) {
            schemaTag.textContent = 'YAML';
        } else {
            schemaTag.textContent = (node.ext || 'text').toUpperCase();
        }

        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 = isEditableZipMember(node) ? 'config bundle' : '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 mode = codeMode(node);
        // Lint (js-yaml + the .zddc schema) only applies to YAML; other text
        // types are plaintext, so skip the lint gutter for them.
        var yamlMode = mode === 'yaml';
        var editor = window.CodeMirror(editorHost, {
            value: text,
            mode: mode,
            lineNumbers: true,
            tabSize: 2,
            indentUnit: 2,
            indentWithTabs: false,
            lineWrapping: false,
            gutters: yamlMode
                ? ['CodeMirror-lint-markers', 'CodeMirror-linenumbers']
                : ['CodeMirror-linenumbers'],
            lint: yamlMode ? { hasGutters: true } : false,
            // 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 (YAML only).
        if (yamlMode) editor.performLint();
        // Schema completion + hover docs for .zddc files (the machine grammar
        // drives keys, enum/boolean values, and nested paths via $ref:"#").
        // Plain .yaml gets no schema (lint + highlighting only).
        var yc = window.app.modules.yamlComplete;
        if (yc && isZddcFile(node.name)) {
            yc.attach(editor, yc.schemaProvider(getZddcSchema), { readOnly: !writable });
        }
        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) {
        return isCodeFile(node);
    }

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