<?php
declare(strict_types=1);

/*
 * All the "content" logic: talking to git, validating input, and turning
 * raw git output into plain PHP arrays. Nothing in this file prints HTML —
 * that's what views/ is for. The one exception is h(), colorize_diff() and
 * markdown_to_html(), which are pure string->string transforms (same
 * category as htmlspecialchars() itself) rather than page markup.
 */

function h(string $s): string {
    return htmlspecialchars($s, ENT_QUOTES, 'UTF-8');
}

/** Every *.css file in THEME_DIR is a theme (just CSS custom properties — see themes/foxygit-dark.css). */
function available_themes(): array {
    $out = [];
    foreach (glob(THEME_DIR . '/*.css') ?: [] as $f) {
        $out[] = basename($f, '.css');
    }
    sort($out);
    return $out !== [] ? $out : [THEME_DEFAULT];
}

function current_theme(): string {
    $themes    = available_themes();
    $requested = $_GET['theme'] ?? $_COOKIE['foxygit_theme'] ?? THEME_DEFAULT;
    if (is_string($requested) && in_array($requested, $themes, true)) return $requested;
    return in_array(THEME_DEFAULT, $themes, true) ? THEME_DEFAULT : $themes[0];
}

/** A theme is "light" if its name ends in -light; everything else counts as dark.
 *  Purely a naming convention — it's what pairs foxygit-dark with foxygit-light. */
function theme_is_dark(string $theme): bool {
    return !str_ends_with($theme, '-light');
}

/** The opposite-mode sibling of a theme (foxygit-dark <-> foxygit-light), or
 *  null when the theme has no counterpart on disk — the sun/moon toggle hides
 *  itself in that case rather than linking somewhere that doesn't exist. */
function theme_counterpart(string $theme): ?string {
    if (str_ends_with($theme, '-dark')) {
        $alt = substr($theme, 0, -strlen('-dark')) . '-light';
    } elseif (str_ends_with($theme, '-light')) {
        $alt = substr($theme, 0, -strlen('-light')) . '-dark';
    } else {
        return null;
    }
    return in_array($alt, available_themes(), true) ? $alt : null;
}

/** Rebuild the current query string with $overrides applied — lets the theme
 *  controls switch theme without losing which repo/view/ref you're looking at. */
function current_query(array $overrides = []): string {
    $params = array_filter($_GET, 'is_string');
    return '?' . http_build_query(array_merge($params, $overrides));
}

/** Run git inside $repo with an argument array. Each arg is shell-escaped.
 *  Line-based: use for commands whose output you want to read as text lines
 *  (log, refs, ls-tree, cat-file -s, ...). Not binary-safe — see git_bytes(). */
function git(string $repo, array $args): array {
    $cmd = 'git -c core.quotepath=false -C ' . escapeshellarg($repo);
    foreach ($args as $a) {
        $cmd .= ' ' . escapeshellarg($a);
    }
    exec($cmd . ' 2>/dev/null', $out);
    return $out;
}

function git_raw(string $repo, array $args): string {
    return implode("\n", git($repo, $args));
}

/** Binary-safe capture of git's stdout, byte for byte (no line splitting/
 *  rejoining). Use for blob content and raw downloads — exec() above would
 *  mangle trailing newlines and can misrepresent binary content. */
function git_bytes(string $repo, array $args): string {
    $cmd = 'git -c core.quotepath=false -C ' . escapeshellarg($repo);
    foreach ($args as $a) {
        $cmd .= ' ' . escapeshellarg($a);
    }
    $proc = proc_open($cmd, [1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes);
    if (!is_resource($proc)) return '';
    $data = stream_get_contents($pipes[1]);
    fclose($pipes[1]);
    fclose($pipes[2]);
    proc_close($proc);
    return $data === false ? '' : $data;
}

/** Cache-busting URL for a static asset — appends the file's mtime as ?v=.
 *  Without it an edited stylesheet keeps being served from the visitor's
 *  browser cache (and, for this site, from Cloudflare's edge cache, which
 *  holds on to .css for a long time), so CSS changes appear not to deploy. */
function asset_url(string $rel): string {
    $mtime = @filemtime(__DIR__ . '/../' . $rel);
    return $mtime !== false ? $rel . '?v=' . $mtime : $rel;
}

/** Validate + resolve a repo name to an absolute path INSIDE REPO_BASE. */
function resolve_repo(?string $name): ?string {
    if ($name === null || !valid_repo_name($name)) {
        return null;                              // reject junk / traversal chars
    }
    $base = realpath(REPO_BASE);
    $path = realpath(REPO_BASE . '/' . $name);
    if ($base === false || $path === false) return null;
    if (strncmp($path, $base . '/', strlen($base) + 1) !== 0) return null; // must be under base
    if (!is_dir($path)) return null;
    if (git($path, ['rev-parse', '--git-dir']) === []) return null;        // must be a git repo
    return $path;
}

/** Same name rule the SSH-side git-shell-commands/{create,delete} enforce:
 *  no path separators, no leading dot (hidden dirs) or dash (looks like a
 *  flag), plain ASCII identifier characters only. */
function valid_repo_name(string $name): bool {
    return $name !== '' && preg_match('~^[A-Za-z0-9._-]+$~', $name) === 1
        && $name[0] !== '.' && $name[0] !== '-';
}

/** Create a new bare repo under REPO_BASE, pre-configured so it accepts
 *  push over authenticated HTTPS (see server/git-http-backend-auth) the
 *  same way server/git-shell-commands/create does for SSH.
 *  @return true|string true on success, an error message otherwise. */
function create_bare_repo(string $name) {
    if (!valid_repo_name($name)) return 'Invalid repo name.';
    $dest = REPO_BASE . '/' . $name . '.git';
    if (file_exists($dest)) return "A repo named \"$name\" already exists.";

    $cmd = 'git init --bare -- ' . escapeshellarg($dest) . ' 2>&1';
    exec($cmd, $out, $status);
    if ($status !== 0) return 'git init failed: ' . implode(' ', $out);

    exec('git -C ' . escapeshellarg($dest) . ' config http.receivepack true 2>&1', $out2, $status2);
    if ($status2 !== 0) return 'repo created, but enabling HTTPS push failed: ' . implode(' ', $out2);

    return true;
}

/** Permanently deletes a bare repo under REPO_BASE.
 *  @return true|string true on success, an error message otherwise. */
function delete_bare_repo(string $name) {
    if (!valid_repo_name($name)) return 'Invalid repo name.';
    $dest = REPO_BASE . '/' . $name . '.git';
    $real = realpath($dest);
    $base = realpath(REPO_BASE);
    if ($real === false || $base === false || strncmp($real, $base . '/', strlen($base) + 1) !== 0) {
        return 'No such repo.';
    }
    exec('rm -rf -- ' . escapeshellarg($real) . ' 2>&1', $out, $status);
    if ($status !== 0) return 'delete failed: ' . implode(' ', $out);
    return true;
}

/** Accept only a plausible ref/path so we never feed dangerous junk to git.
 *  Blocks: empty, overlong, a leading '-' (which git would read as a flag
 *  rather than a revision/path — the actual injection risk), '..' (path
 *  traversal) and raw control characters. Otherwise permissive on purpose:
 *  real filenames use plenty of punctuation and non-ASCII characters (e.g.
 *  åäö), and an ASCII-only whitelist would just make those files unreachable. */
function safe_ref(string $ref): bool {
    return $ref !== ''
        && strlen($ref) <= 200
        && $ref[0] !== '-'
        && strpos($ref, '..') === false
        && !preg_match('~[\x00-\x1f]~', $ref);
}

const IMAGE_EXTENSIONS    = ['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'bmp', 'ico', 'avif'];
const MARKDOWN_EXTENSIONS = ['md', 'markdown', 'mdown', 'mkd'];

function is_image_ext(string $path): bool {
    return in_array(strtolower(pathinfo($path, PATHINFO_EXTENSION)), IMAGE_EXTENSIONS, true);
}

function is_markdown_ext(string $path): bool {
    return in_array(strtolower(pathinfo($path, PATHINFO_EXTENSION)), MARKDOWN_EXTENSIONS, true);
}

/** Last-activity time for a repo without shelling out to git — reads mtimes
 *  of HEAD / the branch it points at / packed-refs. Used for the repo index
 *  so listing many repos doesn't spawn a `git log` per repo on every hit. */
function repo_last_activity(string $path): int {
    $t = [@filemtime($path . '/HEAD') ?: 0, @filemtime($path . '/packed-refs') ?: 0];
    $head = @file_get_contents($path . '/HEAD');
    if ($head !== false && preg_match('~^ref:\s*(\S+)~', trim($head), $m)) {
        $t[] = @filemtime($path . '/' . $m[1]) ?: 0;
    }
    return max($t);
}

/** The repo's description, straight from the standard git `description` file
 *  (the same one `git init` seeds with "Unnamed repository..." — that
 *  placeholder is treated as "no description set", not real content).
 *  Set one with `ssh git@host describe <repo> <text>` — see server/. */
function repo_description(string $path): string {
    $desc = @file_get_contents($path . '/description');
    if ($desc === false || strpos($desc, 'Unnamed repository') !== false) return '';
    return trim($desc);
}

/** File extension -> [language name, GitHub-linguist-style color]. Deliberately
 *  excludes "data"/markup formats (JSON, YAML, Markdown, XML, ...) — same as
 *  GitHub's language bar, so a repo's lockfiles/docs don't dominate the count. */
const LANGUAGE_COLORS = [
    'js' => ['JavaScript', '#f1e05a'], 'mjs' => ['JavaScript', '#f1e05a'], 'cjs' => ['JavaScript', '#f1e05a'],
    'jsx' => ['JavaScript', '#f1e05a'],
    'ts' => ['TypeScript', '#3178c6'], 'tsx' => ['TypeScript', '#3178c6'],
    'py' => ['Python', '#3572A5'], 'pyw' => ['Python', '#3572A5'],
    'php' => ['PHP', '#4F5D95'], 'phtml' => ['PHP', '#4F5D95'],
    'rb' => ['Ruby', '#701516'],
    'go' => ['Go', '#00ADD8'],
    'rs' => ['Rust', '#dea584'],
    'java' => ['Java', '#b07219'],
    'kt' => ['Kotlin', '#A97BFF'], 'kts' => ['Kotlin', '#A97BFF'],
    'swift' => ['Swift', '#F05138'],
    'c' => ['C', '#555555'], 'h' => ['C', '#555555'],
    'cpp' => ['C++', '#f34b7d'], 'cc' => ['C++', '#f34b7d'], 'cxx' => ['C++', '#f34b7d'], 'hpp' => ['C++', '#f34b7d'],
    'cs' => ['C#', '#178600'],
    'sh' => ['Shell', '#89e051'], 'bash' => ['Shell', '#89e051'], 'zsh' => ['Shell', '#89e051'],
    'html' => ['HTML', '#e34c26'], 'htm' => ['HTML', '#e34c26'],
    'css' => ['CSS', '#563d7c'],
    'scss' => ['SCSS', '#c6538c'], 'sass' => ['SCSS', '#c6538c'],
    'vue' => ['Vue', '#41b883'],
    'lua' => ['Lua', '#000080'],
    'pl' => ['Perl', '#0298c3'], 'pm' => ['Perl', '#0298c3'],
    'dart' => ['Dart', '#00B4AB'],
    'm' => ['Objective-C', '#438eff'],
    'scala' => ['Scala', '#c22d40'],
    'hs' => ['Haskell', '#5e5086'],
    'ex' => ['Elixir', '#6e4a7e'], 'exs' => ['Elixir', '#6e4a7e'],
    'erl' => ['Erlang', '#B83998'],
    'clj' => ['Clojure', '#db5855'], 'cljs' => ['Clojure', '#db5855'],
    'r' => ['R', '#198CE7'],
    'jl' => ['Julia', '#a270ba'],
    'elm' => ['Elm', '#60B5CC'],
    'vim' => ['Vim Script', '#199f4b'],
    'ps1' => ['PowerShell', '#012456'],
    'asm' => ['Assembly', '#6E4C13'], 's' => ['Assembly', '#6E4C13'],
    'zig' => ['Zig', '#ec915c'],
    'nim' => ['Nim', '#ffc200'],
    'ml' => ['OCaml', '#3be133'],
    'fs' => ['F#', '#b845fc'],
    'groovy' => ['Groovy', '#4298b8'],
    'coffee' => ['CoffeeScript', '#244776'],
    'sql' => ['SQL', '#e38c00'],
];

/** A handful of extensionless files that are unambiguously one language. */
const LANGUAGE_FILENAMES = [
    'Dockerfile' => ['Dockerfile', '#384d54'],
    'Makefile'   => ['Makefile', '#427819'],
    'Rakefile'   => ['Ruby', '#701516'],
    'Gemfile'    => ['Ruby', '#701516'],
];

/** GitHub-style "language bar" data for a repo: bytes-of-code per language at
 *  $ref, as percentages of the recognized-language total (LANGUAGE_COLORS/
 *  LANGUAGE_FILENAMES only — unrecognized files, same as GitHub's linguist,
 *  don't count toward the total). Sorted by size descending; anything past
 *  the top 5 languages is folded into a trailing "Other" bucket so a
 *  polyglot repo's bar/legend stays readable. Empty array for an empty repo
 *  or one with nothing recognized. */
function repo_language_stats(string $repo, string $ref = 'HEAD'): array {
    $raw = git_raw($repo, ['ls-tree', '-r', '-l', $ref]);
    if ($raw === '') return [];

    $byLang = [];   // language name -> ['bytes' => int, 'color' => hex]
    foreach (explode("\n", $raw) as $line) {
        // <mode> blob <sha> <size>\t<path>
        if (!preg_match('~^\d+\s+blob\s+[0-9a-f]+\s+(\S+)\t(.+)$~', $line, $m)) continue;
        $size = $m[1] === '-' ? 0 : (int) $m[1];
        if ($size <= 0) continue;

        $path = $m[2];
        $lang = LANGUAGE_FILENAMES[basename($path)] ?? LANGUAGE_COLORS[strtolower(pathinfo($path, PATHINFO_EXTENSION))] ?? null;
        if ($lang === null) continue;

        [$name, $color] = $lang;
        $byLang[$name] ??= ['bytes' => 0, 'color' => $color];
        $byLang[$name]['bytes'] += $size;
    }
    if (!$byLang) return [];

    $total = array_sum(array_column($byLang, 'bytes'));
    $stats = [];
    foreach ($byLang as $name => $info) {
        $stats[] = ['name' => $name, 'color' => $info['color'], 'bytes' => $info['bytes'], 'pct' => $info['bytes'] / $total * 100];
    }
    usort($stats, fn($a, $b) => $b['bytes'] <=> $a['bytes']);

    if (count($stats) > 6) {
        $rest      = array_slice($stats, 5);
        $restBytes = array_sum(array_column($rest, 'bytes'));
        $stats     = array_slice($stats, 0, 5);
        $stats[]   = ['name' => 'Other', 'color' => '#8b949e', 'bytes' => $restBytes, 'pct' => $restBytes / $total * 100];
    }
    return $stats;
}

function list_repos(): array {
    $out = [];
    foreach (glob(REPO_BASE . '/*') ?: [] as $p) {
        if (!is_dir($p)) continue;
        if (git($p, ['rev-parse', '--git-dir']) === []) continue;
        $name = basename($p);
        $out[$name] = [
            'desc'      => repo_description($p),
            'mtime'     => repo_last_activity($p),
            'languages' => repo_language_stats($p),
        ];
    }
    uasort($out, fn($a, $b) => $b['mtime'] <=> $a['mtime']);
    return $out;
}

/** Branch names for the branch pills in the repo subnav (log/tree views). */
function list_branches(string $repo): array {
    return git($repo, ['for-each-ref', '--format=%(refname:short)', 'refs/heads']);
}

/** Find a root-level file matching $pattern in $ref's tree (e.g. README, LICENSE). */
function find_root_file(string $repo, string $ref, string $pattern): ?string {
    $out  = git($repo, ['ls-tree', '--name-only', '-z', $ref]);
    $blob = $out[0] ?? '';
    foreach (explode("\0", $blob) as $name) {
        if ($name !== '' && preg_match($pattern, $name)) return $name;
    }
    return null;
}

/** README + LICENSE from a ref's root tree, ready to hand to views/tree.php.
 *  README is rendered to HTML; oversized or binary READMEs are skipped (null).
 *  $repoName is the URL-facing repo name (not the filesystem path in $repo) —
 *  needed so relative links/images inside the README can resolve to real
 *  foxygit URLs (see resolve_md_url()). */
function root_docs(string $repo, string $repoName, string $ref): array {
    $out = ['readmeName' => null, 'readmeHtml' => null, 'licenseName' => null];

    $out['licenseName'] = find_root_file($repo, $ref, '~^(licen[sc]e|copying)(\.(md|txt))?$~i');

    $readme = find_root_file($repo, $ref, '~^readme(\.(md|markdown|mdown|mkd|txt))?$~i');
    if ($readme === null) return $out;

    $sizeOut = git($repo, ['cat-file', '-s', "$ref:$readme"]);
    $size    = isset($sizeOut[0]) ? (int) $sizeOut[0] : PHP_INT_MAX;
    if ($size > MAX_BLOB_BYTES) return $out;

    $content = git_bytes($repo, ['show', "$ref:$readme"]);
    if (strpos($content, "\0") !== false) return $out;   // binary, not a readable README

    $out['readmeName'] = $readme;
    $out['readmeHtml'] = markdown_to_html($content, $repoName, $ref, '');
    return $out;
}

/** Absolute-URL schemes markdown links/images are allowed to point at.
 *  Everything else with a scheme (javascript:, data:, ...) gets dropped. */
const MD_SAFE_SCHEMES = ['http', 'https', 'mailto', 'ftp', 'tel', 'xmpp', 'irc', 'ircs'];

/** Resolve a relative markdown link/image path against the directory
 *  containing the file being rendered ('' for repo root), collapsing "."
 *  and ".." segments the way a browser would for a relative <a href>. */
function resolve_relative_link(string $baseDir, string $rel): string {
    $parts = $baseDir === '' ? [] : explode('/', $baseDir);
    foreach (explode('/', $rel) as $seg) {
        if ($seg === '' || $seg === '.') continue;
        if ($seg === '..') { array_pop($parts); continue; }
        $parts[] = $seg;
    }
    return implode('/', $parts);
}

/** Turn a markdown link/image target into an href foxygit can actually serve.
 *  Absolute URLs pass through unchanged (scheme allow-listed via
 *  MD_SAFE_SCHEMES); '#anchor' and '/root-relative' targets pass through as-is;
 *  everything else is a path relative to $baseDir (the directory of the file
 *  being rendered) and gets rewritten into a link within this same repo+ref —
 *  ?a=raw for images (so <img src> gets real bytes), ?a=tree for links (so
 *  clicking through lands on that file's own foxygit page, anchor preserved).
 *  Returns null when the target should be dropped entirely (unsafe scheme, or
 *  no repo context to resolve a relative path against). */
function resolve_md_url(string $url, string $repoName, string $ref, string $baseDir, bool $isImage): ?string {
    if ($url === '') return null;
    if (preg_match('~^([a-zA-Z][a-zA-Z0-9+.-]*):~', $url, $m)) {
        return in_array(strtolower($m[1]), MD_SAFE_SCHEMES, true) ? $url : null;
    }
    if (str_starts_with($url, '//')) return $url;           // protocol-relative
    if ($url[0] === '#' || $url[0] === '/') return $url;    // in-page anchor / site-root-relative
    if ($repoName === '') return null;

    [$path, $frag] = array_pad(explode('#', $url, 2), 2, '');
    $resolved = resolve_relative_link($baseDir, rawurldecode($path));
    if ($resolved === '') return null;

    $action = $isImage ? 'raw' : 'tree';
    $href   = '?r=' . $repoName . '&a=' . $action . '&ref=' . $ref . '&blob=' . $resolved;
    return $frag !== '' ? $href . '#' . $frag : $href;
}

/** GitHub-style heading slug: strip markdown syntax and punctuation, lowercase,
 *  spaces to hyphens. Caller is responsible for de-duplicating across a document
 *  (GitHub appends -1, -2, ... to repeats) — see markdown_to_html(). */
function md_slug(string $text): string {
    $text = preg_replace('~`([^`]+)`~', '$1', $text);
    $text = preg_replace('~!?\[([^\]]*)\]\([^)]*\)~', '$1', $text);
    $text = preg_replace('#[*_~]+#', '', $text);
    $text = strtolower(trim($text));
    $text = preg_replace('~[^\p{L}\p{N}\s_-]~u', '', $text);
    $text = preg_replace('~\s+~', '-', $text);
    return $text ?? '';
}

/** Split one GFM table row into its cell strings ("| a | b |" and "a | b" alike). */
function md_table_cells(string $line): array {
    $line = trim($line);
    if (str_starts_with($line, '|')) $line = substr($line, 1);
    if (str_ends_with($line, '|')) $line = substr($line, 0, -1);
    $cells = preg_split('~(?<!\\\\)\|~', $line) ?: [''];
    return array_map(fn($c) => str_replace('\\|', '|', trim($c)), $cells);
}

/** README authors routinely mix raw HTML into markdown — wrapping an image in
 *  a centering <div>, a <sub> caption, a <details> spoiler. GitHub renders
 *  that HTML as-is; foxygit's parser previously escaped it all as plain text
 *  (safe, but exactly the "image tag shows up as literal text" bug). This is
 *  a small allow-list of tags/attributes rendered for real; anything else stays
 *  escaped. src/href go through resolve_md_url() same as markdown links/images.
 *  See the inline pass in inline_md() and the block pass in markdown_to_html(). */
const MD_HTML_TAGS = [
    'div' => ['align'], 'p' => ['align'], 'span' => ['align'], 'center' => [],
    'details' => [], 'summary' => [],
    'br' => [], 'hr' => [],
    'b' => [], 'strong' => [], 'i' => [], 'em' => [],
    'sub' => [], 'sup' => [], 'kbd' => [], 'small' => [],
    'img' => ['src', 'alt', 'title', 'width', 'height', 'align'],
    'a'   => ['href', 'title'],
];
const MD_HTML_VOID_TAGS  = ['img', 'br', 'hr'];
/** Tags allowed to open a *block* (alone on their own source line) in
 *  markdown_to_html() — their content is re-parsed as markdown (headings,
 *  images, fenced code, ...), not just run through inline_md() as plain text. */
const MD_HTML_BLOCK_TAGS = ['div', 'p', 'center', 'details', 'summary'];

/** Parse name="value"/name='value' pairs out of a raw HTML tag's attribute text. */
function md_html_attrs(string $attrString): array {
    $attrs = [];
    preg_match_all(
        '~([a-zA-Z][a-zA-Z0-9-]*)\s*=\s*"([^"]*)"|([a-zA-Z][a-zA-Z0-9-]*)\s*=\s*\'([^\']*)\'~',
        $attrString, $ms, PREG_SET_ORDER
    );
    foreach ($ms as $mm) {
        if ($mm[1] !== '') $attrs[strtolower($mm[1])] = $mm[2];
        else $attrs[strtolower($mm[3])] = $mm[4];
    }
    return $attrs;
}

/** Render one allow-listed HTML opening tag with sanitized attributes, or null
 *  if $tag isn't one foxygit renders raw (caller falls back to escaping the
 *  original source text instead — never render a tag we don't recognize). */
function md_render_html_open(string $tag, string $attrString, string $repoName, string $ref, string $baseDir): ?string {
    if (!array_key_exists($tag, MD_HTML_TAGS)) return null;
    $attrs = md_html_attrs($attrString);
    $out = "<$tag";
    foreach (MD_HTML_TAGS[$tag] as $name) {
        if (!isset($attrs[$name])) continue;
        $val = $attrs[$name];
        if ($name === 'src') {
            $resolved = resolve_md_url($val, $repoName, $ref, $baseDir, true);
            if ($resolved === null) continue;
            $val = $resolved;
        } elseif ($name === 'href') {
            $resolved = resolve_md_url($val, $repoName, $ref, $baseDir, false);
            if ($resolved === null) continue;
            $val = $resolved;
        } elseif ($name === 'align') {
            if (!in_array($val, ['left', 'right', 'center', 'justify'], true)) continue;
        } elseif ($name === 'width' || $name === 'height') {
            if (!preg_match('~^\d{1,4}%?$~', $val)) continue;
        }
        $out .= ' ' . $name . '="' . h($val) . '"';
    }
    if ($tag === 'a') $out .= ' rel="nofollow noopener"';
    if ($tag === 'img') $out .= ' loading="lazy"';
    return $out . '>';
}

/** Inline markdown: code/images/links/autolinks applied first via placeholders
 *  (each escapes its own inner content) so nothing gets double-escaped, then
 *  the remaining plain text is escaped and bold/italic/strikethrough applied
 *  on top of that. $repoName/$ref/$baseDir give relative links & images
 *  somewhere real to point at — see resolve_md_url(). */
function inline_md(string $text, string $repoName = '', string $ref = '', string $baseDir = ''): string {
    $store = [];
    $put = function (string $html) use (&$store): string {
        $key = "\x02" . count($store) . "\x03";
        $store[$key] = $html;
        return $key;
    };

    // code spans first so nothing inside `...` is ever treated as markdown
    $text = preg_replace_callback('~`([^`]+)`~', function ($m) use ($put) {
        return $put('<code>' . h($m[1]) . '</code>');
    }, $text);

    // images: ![alt](src "title")
    $text = preg_replace_callback('~!\[([^\]]*)\]\(([^)\s]+)(?:\s+"([^"]*)")?\)~',
        function ($m) use ($put, $repoName, $ref, $baseDir) {
            $src = resolve_md_url($m[2], $repoName, $ref, $baseDir, true);
            if ($src === null) return '';
            $titleAttr = ($m[3] ?? '') !== '' ? ' title="' . h($m[3]) . '"' : '';
            return $put('<img src="' . h($src) . '" alt="' . h($m[1]) . '" loading="lazy"' . $titleAttr . '>');
        }, $text);

    // links: [text](href "title")
    $text = preg_replace_callback('~\[([^\]]+)\]\(([^)\s]+)(?:\s+"([^"]*)")?\)~',
        function ($m) use ($put, $repoName, $ref, $baseDir) {
            $href = resolve_md_url($m[2], $repoName, $ref, $baseDir, false);
            if ($href === null) return h($m[1]);
            $titleAttr = ($m[3] ?? '') !== '' ? ' title="' . h($m[3]) . '"' : '';
            return $put('<a href="' . h($href) . '" rel="nofollow noopener"' . $titleAttr . '>' . h($m[1]) . '</a>');
        }, $text);

    // autolinks: <https://example.com> and bare http(s):// URLs in running text
    $text = preg_replace_callback('~<((?:https?|mailto):[^\s>]+)>~i', function ($m) use ($put) {
        return $put('<a href="' . h($m[1]) . '" rel="nofollow noopener">' . h($m[1]) . '</a>');
    }, $text);
    $text = preg_replace_callback('~\bhttps?://[^\s<>"]+~i', function ($m) use ($put) {
        $url = rtrim($m[0], '.,;:!?)');
        return $put('<a href="' . h($url) . '" rel="nofollow noopener">' . h($url) . '</a>');
    }, $text);

    // a small allow-listed set of raw HTML tags mixed inline with markdown text
    // (<sub>, <kbd>, <br>, an <img>/<a> written as HTML instead of markdown, ...);
    // anything not in MD_HTML_TAGS is left as literal source for h() to escape below.
    $text = preg_replace_callback(
        '~<(/?)([a-zA-Z][a-zA-Z0-9]*)((?:\s+[a-zA-Z][a-zA-Z0-9-]*(?:\s*=\s*(?:"[^"]*"|\'[^\']*\'))?)*)\s*/?>~',
        function ($m) use ($put, $repoName, $ref, $baseDir) {
            $tag = strtolower($m[2]);
            if (!array_key_exists($tag, MD_HTML_TAGS)) return $m[0];
            if ($m[1] === '/') {
                return in_array($tag, MD_HTML_VOID_TAGS, true) ? '' : $put("</$tag>");
            }
            $open = md_render_html_open($tag, $m[3], $repoName, $ref, $baseDir);
            return $open === null ? $m[0] : $put($open);
        }, $text);

    $text = h($text);
    $text = preg_replace('~\*\*([^*]+)\*\*~', '<strong>$1</strong>', $text);
    $text = preg_replace('~(?<!\*)\*([^*\n]+)\*(?!\*)~', '<em>$1</em>', $text);
    $text = preg_replace('#~~(.+?)~~#', '<del>$1</del>', $text);

    // strtr() with an array does one single pass over $text — it never re-scans
    // the replacement HTML it just spliced in. That's fine for one placeholder
    // level, but a badge-style link wrapping an image (`[![alt](img)](href)`)
    // nests an image placeholder *inside* the link's stashed HTML, so it needs
    // a second pass to resolve. Loop until nothing changes (bounded by nesting
    // depth, which here is at most 2: link > image).
    while (strpos($text, "\x02") !== false) {
        $next = strtr($text, $store);
        if ($next === $text) break;   // remaining \x02 bytes are literal source text, not our placeholders
        $text = $next;
    }
    return $text;
}

/** Small, dependency-free markdown → HTML: headings (with GitHub-style anchor
 *  ids so in-page/cross-doc section links work), paragraphs (with hard line
 *  breaks), ordered/unordered lists (nested by indentation), blockquotes,
 *  horizontal rules, GFM tables, fenced code blocks, and the inline formatting
 *  from inline_md(). $repoName/$ref/$baseDir are threaded through to
 *  inline_md() so relative links/images resolve — see resolve_md_url(). */
function markdown_to_html(string $md, string $repoName = '', string $ref = '', string $baseDir = ''): string {
    $blocks = [];
    $md = preg_replace_callback('~```([^\n`]*)\n(.*?)```~s', function ($m) use (&$blocks) {
        $lang  = trim($m[1]);
        $class = $lang !== '' ? ' class="language-' . h($lang) . '"' : '';
        $key   = "\x02fence" . count($blocks) . "\x03";
        $blocks[$key] = "<pre><code$class>" . h(rtrim($m[2], "\n")) . '</code></pre>';
        return $key;
    }, $md);

    $lines = explode("\n", $md);
    $n     = count($lines);
    $html  = [];
    $slugs = [];
    $para  = [];       // list of ['text' => ..., 'break' => bool hard-break-after]
    $listStack = [];   // stack of ['type' => 'ul'|'ol', 'indent' => int]

    $flushPara = function () use (&$para, &$html, $repoName, $ref, $baseDir) {
        if (!$para) return;
        $joined = '';
        $last   = count($para) - 1;
        foreach ($para as $i => $p) {
            $joined .= $p['text'];
            if ($i < $last) $joined .= $p['break'] ? "\x02br\x03" : ' ';
        }
        $text = inline_md($joined, $repoName, $ref, $baseDir);
        $text = str_replace("\x02br\x03", "<br>\n", $text);
        $html[] = '<p>' . $text . '</p>';
        $para = [];
    };
    // A list item's <li> is left unclosed while it might still gain a nested
    // sub-list (indented lines right after it) — closeCurrentLi() closes it
    // once we know no more nesting is coming (a sibling item, a dedent, or
    // the list ending), so nested <ul>/<ol> end up *inside* their parent <li>.
    $closeCurrentLi = function () use (&$listStack, &$html) {
        if ($listStack && end($listStack)['liOpen']) {
            $html[] = '</li>';
            $listStack[count($listStack) - 1]['liOpen'] = false;
        }
    };
    $closeLists = function (int $downTo = 0) use (&$listStack, &$html, $closeCurrentLi) {
        while (count($listStack) > $downTo) {
            $closeCurrentLi();
            $html[] = '</' . array_pop($listStack)['type'] . '>';
        }
    };

    for ($i = 0; $i < $n; $i++) {
        $line     = rtrim($lines[$i]);
        $trimmed  = ltrim($line);
        $indent   = strlen($line) - strlen($trimmed);
        $hardBreak = (bool) preg_match('~(?:[ \t]{2,}|\\\\)$~', $lines[$i]);

        if (preg_match('~^\x02fence\d+\x03$~', $trimmed)) {
            $flushPara(); $closeLists();
            $html[] = $trimmed;
            continue;
        }

        if ($trimmed === '') {
            $flushPara(); $closeLists();
            continue;
        }

        if (preg_match('~^(?:-{3,}|\*{3,}|_{3,})$~', str_replace(' ', '', $trimmed))) {
            $flushPara(); $closeLists();
            $html[] = '<hr>';
            continue;
        }

        if (preg_match('~^(#{1,6})\s+(.*?)\s*#*\s*$~', $trimmed, $m)) {
            $flushPara(); $closeLists();
            $level = strlen($m[1]);
            $slug  = md_slug($m[2]);
            if ($slug !== '') {
                $count = $slugs[$slug] ?? 0;
                $slugs[$slug] = $count + 1;
                if ($count > 0) $slug .= '-' . $count;
            }
            $idAttr = $slug !== '' ? ' id="' . h($slug) . '"' : '';
            $html[] = "<h$level$idAttr>" . inline_md($m[2], $repoName, $ref, $baseDir) . "</h$level>";
            continue;
        }

        // a block-level HTML wrapper alone on its own line (README pattern:
        // <div align="center"> ... </div> around an image, a caption, a whole
        // banner section) — its content is re-parsed as markdown recursively,
        // same trick as blockquotes above, so headings/images/fences inside
        // still render instead of becoming inert once wrapped in HTML.
        if (preg_match('~^<(' . implode('|', MD_HTML_BLOCK_TAGS) . ')((?:\s[^<>]*)?)>$~i', $trimmed, $m)) {
            $flushPara(); $closeLists();
            $tag  = strtolower($m[1]);
            $open = md_render_html_open($tag, $m[2], $repoName, $ref, $baseDir) ?? h($trimmed);

            $depth = 1;
            $inner = [];
            $i++;
            while ($i < $n && $depth > 0) {
                $t = trim(rtrim($lines[$i]));
                if (preg_match('~^<' . $tag . '(?:\s[^<>]*)?>$~i', $t)) {
                    $depth++; $inner[] = $lines[$i]; $i++; continue;
                }
                if (preg_match('~^</' . $tag . '\s*>$~i', $t)) {
                    $depth--;
                    if ($depth === 0) break;
                    $inner[] = $lines[$i]; $i++; continue;
                }
                $inner[] = $lines[$i]; $i++;
            }
            $html[] = $open . markdown_to_html(implode("\n", $inner), $repoName, $ref, $baseDir) . "</$tag>";
            continue;
        }

        if ($trimmed[0] === '>') {
            $flushPara(); $closeLists();
            $quoteLines = [];
            while ($i < $n) {
                $t = trim(rtrim($lines[$i]));
                if ($t === '' || $t[0] !== '>') break;
                $quoteLines[] = preg_replace('~^>\s?~', '', $t);
                $i++;
            }
            $i--;
            $html[] = '<blockquote>' . markdown_to_html(implode("\n", $quoteLines), $repoName, $ref, $baseDir) . '</blockquote>';
            continue;
        }

        if (strpos($trimmed, '|') !== false && isset($lines[$i + 1])
            && preg_match('~^\s*\|?\s*:?-{2,}:?\s*(\|\s*:?-{2,}:?\s*)*\|?\s*$~', $lines[$i + 1])) {
            $flushPara(); $closeLists();
            $headerCells = md_table_cells($trimmed);
            $aligns = array_map(function ($c) {
                $c = trim($c);
                $left  = str_starts_with($c, ':');
                $right = str_ends_with($c, ':');
                if ($left && $right) return 'center';
                if ($right) return 'right';
                if ($left) return 'left';
                return '';
            }, md_table_cells($lines[$i + 1]));

            $rows = [];
            $j = $i + 2;
            while ($j < $n && trim($lines[$j]) !== '' && strpos($lines[$j], '|') !== false) {
                $rows[] = md_table_cells($lines[$j]);
                $j++;
            }
            $i = $j - 1;

            $cellHtml = fn($tag, $cell, $align) => "<$tag" . ($align !== '' ? ' style="text-align:' . $align . '"' : '')
                . '>' . inline_md(trim($cell), $repoName, $ref, $baseDir) . "</$tag>";

            $out = '<table><thead><tr>';
            foreach ($headerCells as $ci => $cell) $out .= $cellHtml('th', $cell, $aligns[$ci] ?? '');
            $out .= '</tr></thead><tbody>';
            foreach ($rows as $row) {
                $out .= '<tr>';
                foreach ($headerCells as $ci => $_) $out .= $cellHtml('td', $row[$ci] ?? '', $aligns[$ci] ?? '');
                $out .= '</tr>';
            }
            $out .= '</tbody></table>';
            $html[] = $out;
            continue;
        }

        if (preg_match('~^([-*+])\s+(.*)$~', $trimmed, $mUl)) {
            $flushPara();
            $type = 'ul'; $content = $mUl[2];
        } elseif (preg_match('~^\d{1,9}[.)]\s+(.*)$~', $trimmed, $mOl)) {
            $flushPara();
            $type = 'ol'; $content = $mOl[1];
        } else {
            $type = null;
        }

        if ($type !== null) {
            while ($listStack && end($listStack)['indent'] > $indent) {
                $closeCurrentLi();
                $html[] = '</' . array_pop($listStack)['type'] . '>';
            }
            if (!$listStack || end($listStack)['indent'] < $indent) {
                // deeper than the current level -> nests inside the still-open parent <li>
                $html[] = "<$type>";
                $listStack[] = ['type' => $type, 'indent' => $indent, 'liOpen' => false];
            } elseif (end($listStack)['type'] !== $type) {
                $closeCurrentLi();
                $html[] = '</' . array_pop($listStack)['type'] . '>';
                $html[] = "<$type>";
                $listStack[] = ['type' => $type, 'indent' => $indent, 'liOpen' => false];
            } else {
                $closeCurrentLi();
            }
            $html[] = '<li>' . inline_md($content, $repoName, $ref, $baseDir);
            $listStack[count($listStack) - 1]['liOpen'] = true;
            continue;
        }

        $para[] = ['text' => $trimmed, 'break' => $hardBreak];
    }
    $flushPara(); $closeLists();

    return strtr(implode("\n", $html), $blocks);
}

function colorize_diff(string $diff): string {
    $lines = [];
    foreach (explode("\n", $diff) as $line) {
        $e = h($line);
        if ($line === '')                    { $lines[] = ''; continue; }
        if (strpos($line, '@@') === 0)         $lines[] = '<span class="hunk">' . $e . '</span>';
        elseif ($line[0] === '+')              $lines[] = '<span class="add">'  . $e . '</span>';
        elseif ($line[0] === '-')              $lines[] = '<span class="del">'  . $e . '</span>';
        else                                   $lines[] = $e;
    }
    return implode("\n", $lines);
}

/** Turn `%H\x1f%h\x1f%an\x1f%at\x1f%s` log lines into plain arrays for views/log.php. */
function parse_log_lines(array $lines): array {
    $out = [];
    foreach ($lines as $l) {
        [$full, $short, $an, $at, $subj] = array_pad(explode("\x1f", $l), 5, '');
        $out[] = [
            'hash'    => $full,
            'short'   => $short,
            'author'  => $an,
            'at'      => $at !== '' ? (int) $at : null,
            'subject' => $subj,
        ];
    }
    return $out;
}

/** Group already-parsed log entries into ['date' => 'YYYY-MM-DD', 'entries' => [...]]
 *  buckets, most recent day first, preserving each day's original commit order. */
function group_log_by_date(array $entries): array {
    $groups = [];
    foreach ($entries as $e) {
        $day = $e['at'] !== null ? date('Y-m-d', $e['at']) : '';
        if (!isset($groups[$day])) $groups[$day] = ['date' => $day, 'entries' => []];
        $groups[$day]['entries'][] = $e;
    }
    return array_values($groups);
}

/** Turn `%(refname:short)\x1f%(refname)` for-each-ref lines into plain arrays for views/refs.php. */
function parse_refs(array $lines): array {
    $out = [];
    foreach ($lines as $r) {
        [$short, $full] = array_pad(explode("\x1f", $r), 2, '');
        $out[] = ['short' => $short, 'kind' => strpos($full, 'refs/tags/') === 0 ? 'tag' : 'branch'];
    }
    return $out;
}

/** Turn `ls-tree --long -z` output into plain arrays for views/tree.php. */
function parse_tree(string $rawTree, string $path): array {
    $entries = $rawTree === '' ? [] : explode("\0", rtrim($rawTree, "\0"));
    $out = [];
    foreach ($entries as $e) {
        // <mode> <type> <object>   <size>\t<name>
        if (!preg_match('~^(\d+)\s+(\w+)\s+[0-9a-f]+\s+(\S+)\t(.+)$~', $e, $m)) continue;
        [, $mode, $type, $size, $name] = $m;
        $out[] = [
            'mode'   => $mode,
            'isTree' => $type === 'tree',
            'size'   => $size === '-' ? '' : $size,
            'name'   => $name,
            'child'  => $path === '' ? $name : "$path/$name",
        ];
    }
    return $out;
}

/** Turn `%H\x1f%an\x1f%ae\x1f%at\x1f%s\x1f%b\x1e`-delimited log output into
 *  plain arrays for views/atom.php and views/atom-tags.php. */
function parse_atom_log(string $raw, string $tag = ''): array {
    $out = [];
    foreach ($raw === '' ? [] : explode("\x1e", $raw) as $rec) {
        $rec = ltrim($rec, "\n");
        if ($rec === '') continue;
        [$hash, $an, $ae, $at, $subj, $body] = array_pad(explode("\x1f", $rec, 6), 6, '');
        $out[] = [
            'hash' => $hash, 'author' => $an, 'email' => $ae,
            'at' => (int) $at, 'subject' => $subj, 'body' => $body, 'tag' => $tag,
        ];
    }
    return $out;
}

const ATOM_LOG_FORMAT = '%H%x1f%an%x1f%ae%x1f%at%x1f%s%x1f%b%x1e';

function atom_commit_entries(string $repo, string $ref): array {
    return parse_atom_log(git_raw($repo, ['log', '-n', (string) ATOM_COUNT, '--pretty=format:' . ATOM_LOG_FORMAT, $ref]));
}

function atom_tag_entries(string $repo): array {
    $out = [];
    foreach (git($repo, ['for-each-ref', '--sort=-creatordate', '--format=%(refname:short)', 'refs/tags']) as $tag) {
        $raw = git_raw($repo, ['log', '-1', '--pretty=format:' . ATOM_LOG_FORMAT, $tag]);
        $entries = parse_atom_log($raw, $tag);
        if ($entries) $out[] = $entries[0];
    }
    return $out;
}

/** The <content type="text"> body of one atom entry: "commit HASH\nAuthor: ...\n\nsubject\n\nbody". */
function atom_content_text(array $entry): string {
    $text = "commit {$entry['hash']}\n";
    if ($entry['author'] !== '') $text .= 'Author: ' . $entry['author'] . ($entry['email'] !== '' ? " <{$entry['email']}>" : '') . "\n";
    if ($entry['at'] > 0)        $text .= 'Date:   ' . date('D M j H:i:s Y O', $entry['at']) . "\n";
    return $text . "\n" . $entry['subject'] . ($entry['body'] !== '' ? "\n\n" . rtrim($entry['body']) : '');
}

/** The clone URL: avoids "repo.git.git" if $repoName already ends in .git. */
function clone_url(string $repoName): string {
    return CLONE_BASE . (str_ends_with($repoName, '.git') ? $repoName : $repoName . '.git');
}

/** Byte count as a human-readable size: "218 B", "1.06 KB", "4.2 MB".
 *  Takes a string because that's what ls-tree gives us, and '' (directories,
 *  which have no size) passes straight through as ''. Trailing zeros are
 *  trimmed so round numbers read "1 KB", not "1.00 KB". */
function format_size(string $bytes): string {
    if ($bytes === '' || !ctype_digit($bytes)) return '';
    $n = (int) $bytes;
    if ($n < 1024) return $n . ' B';

    $units = ['KB', 'MB', 'GB', 'TB', 'PB'];
    $i = -1;
    $v = (float) $n;
    do {
        $v /= 1024;
        $i++;
        // round first: 1048575 B is 1023.999 KB, which would print as "1024 KB"
        // once rounded to 2 decimals -- step up a unit instead.
    } while (round($v, 2) >= 1024 && $i < count($units) - 1);

    return rtrim(rtrim(number_format($v, 2, '.', ''), '0'), '.') . ' ' . $units[$i];
}

/** For each tree entry, the most recent commit that touched its path -- the
 *  "last commit message / updated 4 months ago" columns in views/tree.php.
 *
 *  One `git log` call total, not one per entry: git log accepts several
 *  pathspecs at once and walks history a single time checking commits
 *  against all of them, versus N separate walks for N single-path calls.
 *  --name-only lists, per commit, which of our target paths it touched;
 *  we scan newest-first and take each entry's first (= most recent) match.
 *  A directory entry's "touched" files are anything under it (prefix match)
 *  since git tracks blobs, never bare directory paths, in that list. */
function annotate_last_commits(string $repo, string $ref, array $entries): array {
    if (!$entries) return $entries;

    $paths = array_map(fn($e) => $e['child'], $entries);
    $raw   = git_raw($repo, array_merge(
        ['log', $ref, '--name-only', '--pretty=format:%x1e%H%x1f%s%x1f%at'],
        ['--'], $paths
    ));

    $answers   = [];              // child path -> ['hash', 'subject', 'at']
    $remaining = count($entries);

    foreach ($raw === '' ? [] : explode("\x1e", $raw) as $record) {
        if ($remaining === 0) break;
        if ($record === '') continue;

        $lines  = explode("\n", $record);
        $header = array_shift($lines);
        [$hash, $subject, $at] = array_pad(explode("\x1f", $header), 3, '');

        foreach ($entries as $e) {
            $child = $e['child'];
            if (isset($answers[$child])) continue;
            foreach ($lines as $file) {
                if ($file === $child || ($e['isTree'] && str_starts_with($file, $child . '/'))) {
                    $answers[$child] = ['hash' => $hash, 'subject' => $subject, 'at' => (int) $at];
                    $remaining--;
                    break;
                }
            }
        }
    }

    foreach ($entries as &$e) {
        $info = $answers[$e['child']] ?? null;
        $e['lastHash']    = $info['hash'] ?? '';
        $e['lastSubject'] = $info['subject'] ?? '';
        $e['lastAt']      = $info['at'] ?? null;
    }
    unset($e);
    return $entries;
}

/** "4 months ago", "2 days ago", "just now" -- coarse, GitHub-style relative time. */
function format_relative_time(int $timestamp): string {
    $diff = time() - $timestamp;
    if ($diff < 60) return 'just now';

    foreach ([
        31536000 => 'year', 2592000 => 'month', 604800 => 'week',
        86400 => 'day', 3600 => 'hour', 60 => 'minute',
    ] as $secs => $unit) {
        $n = intdiv($diff, $secs);
        if ($n >= 1) return $n . ' ' . $unit . ($n === 1 ? '' : 's') . ' ago';
    }
    return 'just now';
}

/** Truncate to $max chars with a trailing ellipsis; multibyte-safe without
 *  needing the mbstring extension (not guaranteed installed) -- PCRE's `u`
 *  modifier is built into PHP core, so splitting into UTF-8 characters this
 *  way needs nothing extra. Full text belongs in a title="" attribute
 *  wherever this is used for display. */
function truncate(string $s, int $max): string {
    $chars = preg_split('//u', $s, -1, PREG_SPLIT_NO_EMPTY);
    if ($chars === false || count($chars) <= $max) return $s;
    return implode('', array_slice($chars, 0, $max - 1)) . '…';
}

/** Repo name as shown to a reader. Bare repos are directories called `name.git`,
 *  but that suffix is just noise in headings and listings — so it's dropped for
 *  display only. URLs and clone_url() keep using the real directory name. */
function repo_display_name(string $repoName): string {
    return str_ends_with($repoName, '.git') ? substr($repoName, 0, -strlen('.git')) : $repoName;
}
