<?php
// ═══════════════════════════════════════════════════════════════════════════════════════════════
// PURPOSE
//   Renders the repo's own markdown — READMEs, Manuals, design notes — inside the WebGUI,
//   substituting live conf values into `$VAR` markers so documentation shows what this host
//   is actually configured to do rather than a generic example.
//
// STATUS
//   Not currently wired. Nothing requires this file and there is no docs page or endpoint
//   yet. It is kept because the per-folder README/Manual corpus is exactly what it exists to
//   surface. Written to be safe on the day it is connected — see OPERATIONAL SAFEGUARDS.
//
// DESIGN PRINCIPLES
//   Documentation is discovered, not enumerated.
//       vv_docs_tree() walks SCRIPTS_DIR for *.md. A new folder README appears in the UI
//       with no registration step, which is what keeps the docs from drifting out of the
//       navigation.
//
//   Live values, not example values.
//       `$VAR_NAME` in a markdown file is replaced with that variable's current value from
//       conf. Unresolved names render in a distinct class rather than being left as-is, so a
//       stale variable reference in a doc is visible instead of looking like prose.
//
//   Degrades to readable text without Parsedown.
//       If the bundled renderer is absent the raw markdown is emitted in a <pre> block.
//       Missing a formatter reduces presentation; it never hides the content.
//
// OPERATIONAL SAFEGUARDS
//   Paths are contained to SCRIPTS_DIR.
//       $rel is resolved with realpath() and required to remain under SCRIPTS_DIR, be a
//       regular file, and carry a .md extension. This is deliberate defence for a parameter
//       that will arrive from a request the moment this is wired up — without it, a
//       traversal sequence reaches any file the web user can read.
//
//   Markdown is rendered in safe mode.
//       Parsedown runs with setSafeMode(true), and the <pre> fallback escapes everything.
//       These files are trusted today, but they are also synced between hosts.
//
//   Substituted conf values are escaped.
//       htmlspecialchars() is applied to both the value and the variable name, so a conf
//       value containing markup cannot inject into the rendered page.
//
//   Read-only. Discovers and renders; never writes a doc.
//
// EXPORTS
//   vv_docs_tree()     every *.md under SCRIPTS_DIR, relative paths, sorted
//   vv_docs_render()   one file to HTML with conf substitution applied
//
// CONFIGURATION
//   SCRIPTS_DIR       the containment root and the discovery root
//   PARSEDOWN_PATH    /usr/local/emhttp/plugins/varaverk/lib/Parsedown.php — optional
// ═══════════════════════════════════════════════════════════════════════════════════════════════

require_once __DIR__ . '/config.php';

define('PARSEDOWN_PATH', '/usr/local/emhttp/plugins/varaverk/lib/Parsedown.php');

function vv_docs_tree(): array {
    $base  = SCRIPTS_DIR;
    $tree  = [];
    $files = new RecursiveIteratorIterator(
        new RecursiveDirectoryIterator($base, FilesystemIterator::SKIP_DOTS),
        RecursiveIteratorIterator::SELF_FIRST
    );
    foreach ($files as $f) {
        if ($f->isFile() && strtolower($f->getExtension()) === 'md') {
            $rel    = ltrim(str_replace($base, '', $f->getPathname()), '/');
            $tree[] = $rel;
        }
    }
    sort($tree);
    return $tree;
}

// $keep, when given, selects which `##` sections are rendered. It receives the section heading —
// or null for the title and any preamble before the first one — and returns whether to include
// it. That is what lets one file drive both tiers of a disclosure without a second copy, and
// without inventing markdown syntax to mark the split: the section titles already carry it.
function vv_docs_render(string $rel, array $vars, ?callable $keep = null): string {
    // Containment check — $rel is expected to come from a request parameter once this is
    // wired to a page. Resolve it and require the result to stay inside SCRIPTS_DIR and to
    // still be a .md file, so a traversal sequence cannot reach arbitrary files.
    $base = realpath(SCRIPTS_DIR);
    $path = realpath(SCRIPTS_DIR . '/' . $rel);
    if ($base === false || $path === false)             return '<p>File not found.</p>';
    if (!str_starts_with($path, $base . '/'))           return '<p>File not found.</p>';
    if (strtolower(pathinfo($path, PATHINFO_EXTENSION)) !== 'md') return '<p>File not found.</p>';
    if (!is_file($path))                                return '<p>File not found.</p>';

    return vv_docs_markdown(file_get_contents($path), $vars, $keep);
}

// Markdown → HTML for the subset these docs actually use: headings, paragraphs, bullet and
// ordered lists, tables, blockquotes, fenced code, horizontal rules, and inline bold / italic /
// code / links.
//
// Hand-rolled rather than vendored. Parsedown was the original plan and PARSEDOWN_PATH is still
// honoured below if the file ever appears, but it has never been present on this system, so the
// only path this function ever took was a <pre> dump of raw markdown — which is not a document,
// it is the source of one. A renderer for a subset we control is a few dozen lines and adds
// nothing to a repo that gets pushed.
//
// Escape first, then format. Every line is passed through htmlspecialchars() before any tag is
// introduced, so the only HTML in the output is HTML this function put there. That is what makes
// safe mode unnecessary rather than merely configured — and it is why the $VAR substitution runs
// here, after escaping. Injecting <code> into the markdown before rendering, as this file used to
// do, meant both Parsedown's safe mode and the <pre> fallback escaped the tags and printed them
// as literal text. The feature never worked; nothing called it, so nothing reported it.
// Inline formatting for one line of markdown. Standalone rather than a closure because both the
// full renderer and the brief list below use it — two copies would drift, and the whole point of
// these docs is that the panel and the assistant cannot disagree.
//
// Escape first, then format: the only HTML in the output is HTML this function put there.
function vv_docs_inline(string $s, array $vars): string {
    $s = htmlspecialchars($s, ENT_QUOTES, 'UTF-8');
    // `$VAR` → the live conf value. Unresolved names render in their own class rather than
    // silently reading as prose, so a stale reference in a doc is visible as a defect.
    $s = preg_replace_callback('/`\$([A-Z0-9_]+)`/', function ($m) use ($vars) {
        return isset($vars[$m[1]])
            ? '<code class="vv-live-var">' . htmlspecialchars($vars[$m[1]]) . '</code>'
            : '<code class="vv-unknown-var">$' . $m[1] . '</code>';
    }, $s);
    $s = preg_replace('/`([^`]+)`/', '<code>$1</code>', $s);
    $s = preg_replace('/\*\*([^*]+)\*\*/', '<strong>$1</strong>', $s);
    $s = preg_replace('/(?<![\w*])\*([^*]+)\*(?![\w*])/', '<em>$1</em>', $s);
    // Links are restricted to http/https and relative paths — a doc is trusted, but these files
    // sync between hosts, so javascript: must not be reachable through one.
    $s = preg_replace('/\[([^\]]+)\]\((https?:\/\/[^\s)]+|[^\s):]+)\)/', '<a href="$2">$1</a>', $s);
    return $s;
}

// Terse one-line-per-control list — the shape this help had before it became a document.
// Reads the same markdown as the full render, so the collapsed and expanded views cannot drift.
//
// Only table rows and bullets survive. Paragraphs are dropped on purpose: a list you sweep with
// your eye stops working the moment there is prose between the rows, and that is precisely what
// made the original comb well. Section titles become the full-width divider the old list used.
function vv_docs_brief(string $rel, array $vars, ?callable $keep = null): string {
    $base = realpath(SCRIPTS_DIR);
    $path = realpath(SCRIPTS_DIR . '/' . $rel);
    if ($base === false || $path === false)   return '';
    if (!str_starts_with($path, $base . '/')) return '';
    if (strtolower(pathinfo($path, PATHINFO_EXTENSION)) !== 'md') return '';

    $out  = '';
    $skip = $keep !== null && !$keep(null);
    $rowN = 0;                       // per-table row counter — row 0 is the header, not content

    foreach (explode("\n", str_replace("\r\n", "\n", (string)@file_get_contents($path))) as $line) {
        $t = trim($line);

        if (preg_match('/^(#{1,6})\s+(.*)$/', $t, $m)) {
            $rowN = 0;
            $skip = $keep !== null && !$keep(strlen($m[1]) === 1 ? null : $m[2]);
            // "Reference — the controls on a job row" reads as "the controls on a job row" once
            // the whole list is reference material.
            if (!$skip && strlen($m[1]) > 1) {
                $h = preg_replace('/^Reference\s*—\s*/u', '', $m[2]);
                $out .= '<li class="vv-info-sep">' . vv_docs_inline($h, $vars) . "</li>\n";
            }
            continue;
        }
        if ($skip || $t === '') { if ($t === '') $rowN = 0; continue; }

        if (preg_match('/^[-*]\s+(.*)$/', $t, $m)) {
            $out .= '<li>' . vv_docs_inline($m[1], $vars) . "</li>\n";
            continue;
        }

        if (strpos($t, '|') !== false && substr_count($t, '|') >= 2) {
            if (preg_match('/^\|?[\s:-]*-[\s|:-]*\|/', $t)) continue;      // the --- separator
            $cells = array_map('trim', explode('|', trim($t, '| ')));
            if ($rowN++ === 0) continue;                                   // header row
            $term = array_shift($cells);
            $out .= '<li><strong>' . vv_docs_inline($term, $vars) . '</strong> — '
                  . vv_docs_inline(implode(' · ', array_filter($cells)), $vars) . "</li>\n";
        }
    }
    return $out;
}

function vv_docs_markdown(string $md, array $vars, ?callable $keep = null): string {
    if (file_exists(PARSEDOWN_PATH)) {
        require_once PARSEDOWN_PATH;
        $pd = new Parsedown();
        $pd->setSafeMode(true);
        return $pd->text($md);
    }

    $inline = fn(string $s): string => vv_docs_inline($s, $vars);

    $out = '';
    $list = null;          // 'ul' | 'ol' | null
    $inTable = false;
    $inCode = false;
    $para = [];
    $quote = [];
    $li = [];

    $flushPara = function () use (&$para, &$out, $inline) {
        if (!$para) return;
        $out .= '<p>' . $inline(implode(' ', $para)) . "</p>\n";
        $para = [];
    };
    $flushQuote = function () use (&$quote, &$out, $inline) {
        if (!$quote) return;
        $out .= '<blockquote>' . $inline(implode(' ', $quote)) . "</blockquote>\n";
        $quote = [];
    };
    // A list item is buffered rather than emitted on sight, because markdown wraps: a bullet
    // whose text runs onto the next line is one item, not an item followed by a paragraph.
    $flushLi = function () use (&$li, &$out, $inline) {
        if (!$li) return;
        $out .= '<li>' . $inline(implode(' ', $li)) . "</li>\n";
        $li = [];
    };
    $closeList = function () use (&$list, &$out, &$flushLi) {
        $flushLi();
        if ($list) { $out .= "</$list>\n"; $list = null; }
    };
    $closeTable = function () use (&$inTable, &$out) {
        if ($inTable) { $out .= "</tbody></table>\n"; $inTable = false; }
    };

    // Content before the first heading belongs to the same null-headed group as the title.
    $skip = $keep !== null && !$keep(null);

    foreach (explode("\n", str_replace("\r\n", "\n", $md)) as $line) {
        // Fence state is tracked even inside a skipped section, or a ``` that is being dropped
        // would leave the parser convinced every following line is code.
        if (preg_match('/^```/', $line)) {
            if (!$skip) {
                $flushPara(); $closeList(); $closeTable();
                $out .= $inCode ? "</code></pre>\n" : '<pre class="vv-doc-code"><code>';
            }
            $inCode = !$inCode;
            continue;
        }
        if ($inCode) {
            if (!$skip) $out .= htmlspecialchars($line, ENT_QUOTES, 'UTF-8') . "\n";
            continue;
        }

        $t = trim($line);

        // Headings are resolved before the skip test, because a heading is what changes it.
        if (preg_match('/^(#{1,6})\s+(.*)$/', $t, $m)) {
            $flushQuote(); $flushPara(); $closeList(); $closeTable();
            $n = strlen($m[1]);
            // A level-1 heading is the document title, not a section — it and the preamble that
            // follows are offered to $keep as null so a caller can take or leave them as a unit.
            $skip = $keep !== null && !$keep($n === 1 ? null : $m[2]);
            if (!$skip) $out .= "<h$n>" . $inline($m[2]) . "</h$n>\n";
            continue;
        }
        if ($skip) continue;

        // One guard rather than a flush in every branch: the quote ends the moment a line is
        // not a quote line, whatever that next line turns out to be.
        if ($quote && !str_starts_with($t, '>')) $flushQuote();

        if ($t === '')                       { $flushPara(); $closeList(); $closeTable(); continue; }
        if (preg_match('/^(---+|\*\*\*+)$/', $t)) { $flushPara(); $closeList(); $closeTable(); $out .= "<hr>\n"; continue; }

        // Consecutive > lines are one quote, not one per line — same wrapping convention as
        // paragraphs, which is how they are written.
        if (preg_match('/^>\s?(.*)$/', $t, $m)) {
            $closeList(); $closeTable();
            if (!$quote) $flushPara();
            $quote[] = $m[1];
            continue;
        }

        // Tables: a header row, a separator of dashes, then body rows. The separator is what
        // identifies the block — a lone pipe in prose is not a table.
        if (strpos($t, '|') !== false && preg_match('/^\|?[\s:-]*-[\s|:-]*\|/', $t)) {
            continue;                                    // separator consumed by the header below
        }
        if (strpos($t, '|') !== false && substr_count($t, '|') >= 2) {
            $cells = array_map('trim', explode('|', trim($t, '| ')));
            if (!$inTable) {
                $flushPara(); $closeList();
                $out .= '<table class="vv-doc-table"><thead><tr>';
                foreach ($cells as $c) $out .= '<th>' . $inline($c) . '</th>';
                $out .= "</tr></thead><tbody>\n";
                $inTable = true;
            } else {
                $out .= '<tr>';
                foreach ($cells as $c) $out .= '<td>' . $inline($c) . '</td>';
                $out .= "</tr>\n";
            }
            continue;
        }
        $closeTable();

        if (preg_match('/^[-*]\s+(.*)$/', $t, $m)) {
            $flushPara();
            if ($list !== 'ul') { $closeList(); $out .= "<ul>\n"; $list = 'ul'; }
            else                { $flushLi(); }
            $li[] = $m[1];
            continue;
        }
        if (preg_match('/^\d+\.\s+(.*)$/', $t, $m)) {
            $flushPara();
            if ($list !== 'ol') { $closeList(); $out .= "<ol>\n"; $list = 'ol'; }
            else                { $flushLi(); }
            $li[] = $m[1];
            continue;
        }

        // Lazy continuation: plain text directly under an open list item belongs to that item.
        // Without this a wrapped bullet renders as a bullet plus an orphan paragraph, which is
        // how the first version of this shipped.
        if ($list && $li) { $li[] = $t; continue; }

        $para[] = $t;
    }

    $flushQuote(); $flushPara(); $closeList(); $closeTable();
    if ($inCode) $out .= "</code></pre>\n";
    return $out;
}
