<?php
// ═══════════════════════════════════════════════════════════════════════════════════════════════
// PURPOSE
//   Draws a parsed conf field as the control it deserves, and reads the edits back out. One
//   implementation for every page that edits settings, so a switch looks and behaves the same
//   wherever it is met.
//
// WHY THIS EXISTS
//   Ten pages edited settings and nine had written their own vocabulary to do it — nine sets of
//   inline CSS, six different toggle switches, and two pages that had both claimed the vv-set-*
//   prefix with disjoint class names, so it read as shared and was not. Unraid swaps tabs by AJAX
//   without unloading, so two of those stylesheets can be live in one document at once.
//
//   Each of them started for the same reason: the shared set could render a conf field as a text
//   box and nothing else, and every page eventually needed one control it did not have. The fix
//   is not a convention that asks people not to do that again — it is having the control here.
//
// WHAT DECIDES A CONTROL
//   Not this file. confform.php infers a widget from the conf itself — the value's shape and the
//   comments already written above it — and this renders whatever it was told. That split is
//   deliberate: the schema lives in the conf, the appearance lives here, and neither can quietly
//   start deciding the other.
//
// DESIGN PRINCIPLES
//   The control is a view of the value, never a second copy of it.
//       Every control carries data-key, data-file, data-type and data-orig, and the payload is
//       rebuilt by reading the DOM at save time. There is no parallel model to fall out of step
//       with what is on screen.
//
//   Only what changed is sent.
//       A field whose value still equals data-orig is omitted. A settings page that submits all
//       four hundred fields rewrites four hundred lines to change one, and every one of those
//       rewrites is a chance to lose a comment.
//
//   A value is returned exactly as it was typed.
//       No trimming beyond the edges, no case folding, no normalising true to 1. The conf is
//       shell source and the consuming script decides what it means.
//
// OPERATIONAL SAFEGUARDS
//   Everything is escaped on the way in.
//       Values come from conf files that hold paths, globs and quotes. Rendering goes through
//       one escape helper and attributes through another — vvEscHtml does not escape quotes, so
//       an attribute needs vvEscAttr or a path containing one closes it and the rest executes.
//
//   Secrets render masked and are never pre-filled from a stale read.
//       A masked field left untouched sends nothing, so a save cannot round-trip a credential
//       back through the browser.
//
//   Nothing here writes. The payload goes to api/confform.php, which owns the allowlist, and
//   from there to vv_conf_edit(), which owns the lock, the backup and the rollback.
//
// EXPORTS
//   vv_conf_ui_assets()   the renderer, emitted once per page
//
// DEPENDS ON
//   include/confform.php   the widget each field was inferred to want
//   api/confform.php       ?sections= to read, POST changes to write
//   css/varaverk.css       the vv-cf-* family these classes belong to
// ═══════════════════════════════════════════════════════════════════════════════════════════════

// Required, not assumed. This file emits the renderer for fields that confform.php parses, and
// the pages that include it also read its registries — VV_UI_SECTION_SURFACES among them. Left to
// whichever page happened to have pulled it in first, that works everywhere it was tested and
// fatals on the one page that included this without it, which is what took the AI tab blank.
require_once __DIR__ . '/confform.php';

// A whole settings card: disclosure, filter, save, and the fields for one subject. Emitted by a
// page in one line rather than assembled there.
//
// This exists because the alternative was six copies. The AI tab, the Scheduler and the Settings
// page each grew their own load/filter/dirty/save cycle, and the third one was written by copying
// the second — which is exactly how this plugin ended up with nine settings vocabularies and six
// toggles. Everything subtle here is subtle because it already cost something once: the filter
// hides rather than removes so a pending edit survives it, the save rebases instead of refetching
// so the field under the cursor does not repaint, and a refused write leaves the row dirty
// because it genuinely did not happen.
//
//   $prefix  composes every id, so a page may hold more than one card
//   $match   which sections — a whole word matched against section headers, or "*" for all
//   $title   the card's heading
function vv_conf_ui_card(string $prefix, string $match, string $title = 'Settings'): void {
    vv_conf_ui_assets();
    $p = htmlspecialchars($prefix, ENT_QUOTES);
    ?>
<div class="vv-cf-card" id="<?= $p ?>-card">
  <div class="vv-cf-card-h" id="<?= $p ?>-t">
    <span class="vv-cf-card-c">▶</span>
    <span class="vv-cf-card-l"><?= htmlspecialchars($title) ?></span>
    <input type="text" class="vv-cf-card-f" id="<?= $p ?>-filter"
           placeholder="filter…" style="display:none">
    <span class="vv-cf-card-s" id="<?= $p ?>-sum"></span>
    <button class="vv-cf-card-save" id="<?= $p ?>-save" type="button"
            style="display:none" disabled>Save</button>
  </div>
  <div id="<?= $p ?>-body" style="display:none">
    <div id="<?= $p ?>-fields"><p class="vv-cf-empty">opens when you do</p></div>
  </div>
</div>
<script>VvConfCard(<?= json_encode($prefix) ?>, <?= json_encode($match) ?>);</script>
    <?php
}

function vv_conf_ui_assets(): void {
    static $done = false;
    if ($done) return;
    $done = true;
    ?>
<script>
(function () {
  if (window.VvConfUI) return;

  // The page's own escape helpers where they exist. They are global in Varaverk.page for exactly
  // this reason, and a second private copy here is a second thing to fix when one of them is
  // found to be wrong.
  const esc  = s => (window.vvEscHtml ? vvEscHtml(s) : String(s == null ? '' : s)
      .replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'));
  const escA = s => (window.vvEscAttr ? vvEscAttr(s) : String(s == null ? '' : s)
      .replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;')
      .replace(/"/g,'&quot;').replace(/'/g,'&#39;'));

  // Shared by every control. data-orig is what "changed" is measured against, and it is the
  // parsed value rather than anything the page derived — so a control that renders a value
  // differently than it stores it still compares against the truth.
  function attrs(f) {
    return ` data-key="${escA(f.key)}" data-file="${escA(f.file)}"`
         + ` data-type="${escA(f.type || 'scalar')}" data-orig="${escA(f.value == null ? '' : f.value)}"`;
  }

  function boolCtl(f) {
    const on = String(f.value).toLowerCase() === 'true';
    return `<div class="vv-cf-ctl">
      <label class="vv-cf-toggle">
        <span class="vv-cf-track${on ? ' on' : ''}"></span>
        <input type="checkbox" hidden${attrs(f)} data-ctl="bool"${on ? ' checked' : ''}>
        <span class="vv-cf-state">${on ? 'true' : 'false'}</span>
      </label>
    </div>`;
  }

  function intCtl(f) {
    const min = (f.min !== undefined && f.min !== null) ? ` min="${escA(f.min)}"` : '';
    const max = (f.max !== undefined && f.max !== null) ? ` max="${escA(f.max)}"` : '';
    const unit = f.unit ? `<span class="vv-cf-unit">${esc(f.unit)}</span>` : '';
    return `<div class="vv-cf-ctl">
      <input type="number" class="vv-cf-scalar vv-cf-int"${min}${max}
             value="${escA(f.value)}"${attrs(f)} data-ctl="int">${unit}
    </div>`;
  }

  function enumCtl(f) {
    const cur = String(f.value);
    const opts = (f.choices || []).map(c =>
      `<option value="${escA(c.value)}"${c.value === cur ? ' selected' : ''}>${esc(c.value)}</option>`
    ).join('');
    // The hint belongs to the selected choice, so it is rebuilt on change rather than listing
    // every option's explanation at once — which on a five-way choice is a paragraph.
    const hints = {};
    (f.choices || []).forEach(c => { if (c.hint) hints[c.value] = c.hint; });
    const curHint = hints[cur] || '';
    return `<div class="vv-cf-ctl">
      <select class="vv-cf-sel"${attrs(f)} data-ctl="enum"
              data-hints="${escA(JSON.stringify(hints))}">${opts}</select>
    </div><div class="vv-cf-opthint">${esc(curHint)}</div>`;
  }

  function secretCtl(f) {
    // Rendered from the real value so the field is genuinely editable, but typed as a password so
    // it is not read over a shoulder or captured in a screenshot of the tab.
    return `<div class="vv-cf-ctl">
      <input type="password" class="vv-cf-scalar vv-cf-secret" autocomplete="new-password"
             value="${escA(f.value)}"${attrs(f)} data-ctl="text">
      <button type="button" class="vv-cf-reveal" data-reveal>Show</button>
    </div>`;
  }

  // The gutter is seeded here and then kept in step by the input/scroll handlers in wire().
  // It cannot be produced once and left: the numbers follow the textarea's value, which is the
  // thing the operator is about to change.
  function linesCtl(f) {
    const v = String(f.value == null ? '' : f.value);
    const n = v.split('\n').length;
    return `<div class="vv-cf-arraywrap">
      <pre class="vv-cf-lines" aria-hidden="true">${gutterFor(v)}</pre>
      <textarea class="vv-cf-array" rows="${Math.min(Math.max(n, 3), 20)}"${attrs(f)}
                data-ctl="text" spellcheck="false">${esc(v)}</textarea>
    </div>`;
  }

  function gutterFor(v) {
    const n = String(v).split('\n').length;
    let s = '';
    for (let i = 1; i <= n; i++) s += i + '\n';
    return s;
  }

  // Kept together, because they are two halves of one illusion: the gutter is a separate element
  // that has to be renumbered when the text changes and scrolled when the text scrolls, or the
  // numbers drift away from the lines they belong to.
  function syncGutter(ta) {
    const g = ta.parentElement && ta.parentElement.querySelector('.vv-cf-lines');
    if (!g) return;
    g.textContent = gutterFor(ta.value);
    g.scrollTop   = ta.scrollTop;
  }
  function scrollGutter(ta) {
    const g = ta.parentElement && ta.parentElement.querySelector('.vv-cf-lines');
    if (g) g.scrollTop = ta.scrollTop;
  }

  function textCtl(f) {
    return `<div class="vv-cf-ctl">
      <input type="text" class="vv-cf-scalar" value="${escA(f.value)}"${attrs(f)}
             data-ctl="text" spellcheck="false">
    </div>`;
  }

  const CONTROLS = { bool: boolCtl, int: intCtl, enum: enumCtl, secret: secretCtl,
                     lines: linesCtl, path: textCtl, text: textCtl };

  // n is the running number across the whole panel, not the position within a group. It gives
  // every setting one unambiguous handle — "number 12" beats "the third one under Docker
  // Watchdog" when someone is reading it back to you over the phone. The stripe is driven by the
  // same counter rather than :nth-child, because the group header is also a child and would
  // throw the parity off inside every section.
  function fieldHtml(f, n) {
    const draw = CONTROLS[f.widget] || textCtl;
    // The description is the conf's own comment. It is the only documentation most of these
    // settings have, and it is why the form is readable at all.
    const desc = f.desc ? `<div class="vv-cf-desc">${esc(f.desc)}</div>` : '';
    return `<div class="vv-cf-field${n % 2 === 0 ? ' vv-cf-alt' : ''}" data-field="${escA(f.key)}">
      <span class="vv-cf-num">${n}</span>
      <span class="vv-cf-dot"></span>
      <div class="vv-cf-body">
        <div class="vv-cf-key">${esc(f.key)}</div>
        ${desc}
        ${draw(f)}
      </div>
    </div>`;
  }

  // Takes the counter by reference so numbering continues across groups. Returning it would have
  // worked too, but every caller would then be responsible for threading it correctly and one of
  // them eventually would not.
  function groupHtml(g, counter) {
    const fields = (g.fields || []).map(f => fieldHtml(f, ++counter.n)).join('');
    if (!fields) return '';
    return `<div class="vv-cf-group">
      <div class="vv-cf-group-header">${esc(g.subsection || g.name || '')}
        <span class="vv-cf-file">${esc(g.file || '')}</span>
      </div>
      ${fields}
    </div>`;
  }

  // Reading a control back. Kept beside the drawing so a new control cannot be added without a
  // matching way to read it — the two halves drifting is how a field silently stops saving.
  function readCtl(el) {
    if (el.dataset.ctl === 'bool') return el.checked ? 'true' : 'false';
    return el.value;
  }

  window.VvConfUI = {
    // The markup on its own, for a caller composing it into something larger — the Scheduler's
    // advanced view wraps it in a Config block alongside the header and README sections. Whoever
    // uses this owes the DOM a hydrate() afterwards.
    html(groups) {
      const counter = { n: 0 };
      return (groups || []).map(g => groupHtml(g, counter)).join('');
    },

    // Everything that can only be done once the markup is in the document. Separate from html()
    // because a caller that built a bigger string still needs this half, and separate from wire()
    // because wire() is idempotent per container while this must run after every re-render.
    hydrate(into) {
      const box = typeof into === 'string' ? document.getElementById(into) : into;
      if (!box) return;
      // Seeded from the rendered DOM rather than at build time, so a textarea the browser sized
      // differently than expected still gets a gutter matching what is on screen.
      box.querySelectorAll('.vv-cf-array').forEach(syncGutter);
      return box;
    },

    render(into, groups) {
      const box = typeof into === 'string' ? document.getElementById(into) : into;
      if (!box) return;
      const html = this.html(groups);
      box.innerHTML = html || '<p class="vv-cf-empty">Nothing configurable here.</p>';
      this.hydrate(box);
      return box;
    },

    // Only what actually differs from what was parsed. See the note on partial saves above.
    collect(into) {
      const box = typeof into === 'string' ? document.getElementById(into) : into;
      if (!box) return [];
      const out = [];
      box.querySelectorAll('[data-ctl]').forEach(el => {
        const now = readCtl(el);
        if (now === el.dataset.orig) return;
        out.push({ file: el.dataset.file, key: el.dataset.key,
                   type: el.dataset.type || 'scalar', value: now });
      });
      return out;
    },

    dirtyCount(into) { return this.collect(into).length; },

    // Called after a successful save so the next collect() measures against what is now on disk,
    // without a refetch. A reload would also work and would repaint every control the operator
    // is looking at, including the one they just changed.
    commit(into) {
      const box = typeof into === 'string' ? document.getElementById(into) : into;
      if (!box) return;
      box.querySelectorAll('[data-ctl]').forEach(el => { el.dataset.orig = readCtl(el); });
      box.querySelectorAll('.vv-cf-field.dirty').forEach(f => f.classList.remove('dirty'));
    },

    // One delegated listener for a whole container, however many fields it holds.
    wire(into, onChange) {
      const box = typeof into === 'string' ? document.getElementById(into) : into;
      if (!box || box.dataset.vvWired === '1') return;
      box.dataset.vvWired = '1';

      const refresh = el => {
        const field = el.closest('.vv-cf-field');
        if (field) field.classList.toggle('dirty', readCtl(el) !== el.dataset.orig);
        if (onChange) onChange();
      };

      box.addEventListener('input', e => {
        if (!e.target.dataset.ctl) return;
        if (e.target.classList.contains('vv-cf-array')) syncGutter(e.target);
        refresh(e.target);
      });
      // Capture: scroll does not bubble, so a listener on the container never sees a textarea
      // scrolling inside it any other way.
      box.addEventListener('scroll', e => {
        if (e.target.classList && e.target.classList.contains('vv-cf-array')) scrollGutter(e.target);
      }, true);
      box.addEventListener('change', e => {
        const el = e.target;
        if (!el.dataset.ctl) return;
        if (el.dataset.ctl === 'bool') {
          // The switch and the word beside it are both views of the checkbox, which is the only
          // thing actually holding the state.
          const wrap = el.closest('.vv-cf-toggle');
          if (wrap) {
            const track = wrap.querySelector('.vv-cf-track');
            const state = wrap.querySelector('.vv-cf-state');
            if (track) track.classList.toggle('on', el.checked);
            if (state) state.textContent = el.checked ? 'true' : 'false';
          }
        }
        if (el.dataset.ctl === 'enum') {
          const hint = el.closest('.vv-cf-body')?.querySelector('.vv-cf-opthint');
          if (hint) {
            let hints = {};
            try { hints = JSON.parse(el.dataset.hints || '{}'); } catch (_) {}
            hint.textContent = hints[el.value] || '';
          }
        }
        refresh(el);
      });

      box.addEventListener('click', e => {
        const b = e.target.closest('[data-reveal]');
        if (!b) return;
        const inp = b.closest('.vv-cf-ctl')?.querySelector('input');
        if (!inp) return;
        const shown = inp.type === 'text';
        inp.type = shown ? 'password' : 'text';
        b.textContent = shown ? 'Show' : 'Hide';
      });
    },
  };

  // One card, self-contained. Everything a page used to write for itself.
  window.VvConfCard = function (prefix, match) {
    const $ = s => document.getElementById(prefix + '-' + s);
    const API = '/plugins/varaverk/api/confform.php';
    let loaded = false;

    const dirty = () => {
      const n = VvConfUI.dirtyCount(prefix + '-fields');
      $('save').disabled = (n === 0);
      $('sum').textContent = n ? (n + ' unsaved') : '';
      $('sum').className = 'vv-cf-card-s' + (n ? ' warn' : '');
    };

    function load() {
      if (loaded) return;
      loaded = true;
      fetch(API + '?sections=' + encodeURIComponent(match))
        .then(r => r.json())
        .then(d => {
          if (!d.ok) throw new Error(d.error || 'could not be read');
          VvConfUI.render(prefix + '-fields', d.groups || []);
          VvConfUI.wire(prefix + '-fields', dirty);
          dirty();
        })
        .catch(e => {
          // Retryable — closing and reopening tries again rather than leaving a permanent error.
          loaded = false;
          $('fields').innerHTML = '<p class="vv-cf-empty">could not be read: '
                                + String(e.message || e) + '</p>';
        });
    }

    $('t').addEventListener('click', e => {
      // The filter and the save live in the header; clicking either must not collapse the card.
      if (e.target.closest('#' + prefix + '-filter, #' + prefix + '-save')) return;
      const open = $('body').style.display === 'none';
      $('body').style.display = open ? '' : 'none';
      $('t').classList.toggle('open', open);
      $('filter').style.display = open ? '' : 'none';
      $('save').style.display   = open ? '' : 'none';
      // First open only, so collapsing to look at something else does not discard edits.
      if (open) load();
    });

    // Hides whole sections, never individual fields — a setting means little without the heading
    // saying what it belongs to. display:none rather than removal, so a field edited before
    // filtering is still dirty and still saved: a filter that silently dropped pending edits
    // would be a data-loss bug wearing a search box.
    $('filter').addEventListener('input', () => {
      const q = $('filter').value.trim().toLowerCase();
      $('fields').querySelectorAll('.vv-cf-group').forEach(g => {
        g.classList.toggle('vv-filtered', q !== '' && !g.textContent.toLowerCase().includes(q));
      });
    });

    $('save').addEventListener('click', () => {
      const changes = VvConfUI.collect(prefix + '-fields');
      if (!changes.length) return;
      $('save').disabled = true;
      $('sum').textContent = 'saving…';
      $('sum').className = 'vv-cf-card-s';

      // URLSearchParams, not FormData — a multipart POST to this plugin's endpoints hangs with
      // no status ever returned.
      fetch(API, { method: 'POST',
        headers: { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8' },
        body: new URLSearchParams({ changes: JSON.stringify(changes) }) })
        .then(r => r.text())
        .then(t => {
          let d;
          try { d = JSON.parse(t); }
          catch (_) {
            throw new Error(t.trim() ? 'unparseable response'
                                     : 'empty response — rejected before the endpoint ran');
          }
          if (!d.ok) throw new Error(d.error || 'save failed');
          // Rebased, not refetched: the values on screen are the values on disk now, and a
          // reload would repaint the control under the cursor.
          VvConfUI.commit(prefix + '-fields');
          dirty();
          $('sum').textContent = 'saved ' + changes.length
                               + ' change' + (changes.length > 1 ? 's' : '');
          $('sum').className = 'vv-cf-card-s ok';
        })
        .catch(e => {
          // Left dirty on purpose. A refused write changed nothing, and clearing the marks
          // would say it had.
          $('sum').textContent = String(e.message || e);
          $('sum').className = 'vv-cf-card-s bad';
          dirty();
        });
    });
  };
})();
</script>
    <?php
}
