#!/usr/bin/env php
<?php
// =============================================================================
// eventplan_tool — the Non-profit and events tools' work, for the tool presets (tools_builtins/eventplan.json). A command-line program, never reachable over the web.
//
//   php eventplan_tool roster   ROSTER.csv   OUTPUT [maxday=8]    volunteer roster: double-booked people, uncovered shifts, hours per person (columns: person, date, start, end, role, needed)
//   php eventplan_tool timeline RUNSHEET.csv OUTPUT               run of show: overlaps, gaps, total length (columns: start, end or minutes, item)
//   php eventplan_tool budget   BUDGET.csv   OUTPUT               budget against actual by line: variance, lines over budget, totals (columns: line, budget, actual, optional category)
//   php eventplan_tool grant    DRAFT.md     OUTPUT CHECKLIST     a grant draft against its checklist ("Section | max words" per line): sections missing, word counts over or far under the limit
//
// Arithmetic and cross-checking on your own files. Not financial, legal or grant-writing advice, and the tool does not know a funder's rules: the checklist is yours. The original is never changed.
// Problems come back as file(line) Error|Warning|Note: ...
// =============================================================================
if (PHP_SAPI !== 'cli') { http_response_code(403); exit; }
function fail(string $m, int $rc = 1): void { fwrite(STDERR, "eventplan_tool: $m\n"); exit($rc); }
[$sub, $in, $out] = [$argv[1] ?? '', $argv[2] ?? '', $argv[3] ?? '']; $args = array_slice($argv, 4);
if (!in_array($sub, ['roster', 'timeline', 'budget', 'grant'], true) || $in === '' || $out === '') fail('usage: eventplan_tool roster|timeline|budget|grant INPUT OUTPUT [maxday=N | CHECKLIST]', 2);
function load(string $f): string { if (!is_file($f)) fail("no such file: $f"); $r = (string)file_get_contents($f); if (strncmp($r, "\xEF\xBB\xBF", 3) === 0) $r = substr($r, 3); if (!mb_check_encoding($r, 'UTF-8')) fail("not valid UTF-8: $f"); $r = str_replace(["\r\n", "\r"], "\n", $r); if (trim($r) === '') fail("the file is empty: $f"); return $r; }
$raw = load($in); $name = basename($in); $opt = []; foreach ($args as $a) if (preg_match('/^(\w+)=(.*)$/', trim($a), $m)) $opt[strtolower($m[1])] = trim($m[2]);
$msgs = []; $nerr = 0; $nwarn = 0;
$add = function (string $k, int $l, string $m) use (&$msgs, &$nerr, &$nwarn, $name) { $msgs[] = "$name($l) $k: $m"; if ($k === 'Error') $nerr++; elseif ($k === 'Warning') $nwarn++; };
function finish(string $out, string $body, array $msgs, int $nerr, int $nwarn): void {
    file_put_contents($out, $body . "\n## Problems found\n\n" . ($msgs ? implode("\n", $msgs) . "\n" : "None.\n") . "\n$nerr errors, $nwarn warnings.\n[exit code 0]\n"); echo "Wrote $out ($nerr errors, $nwarn warnings)\n"; exit(0);
}
function read_csv(string $raw): array {
    $first = strtok($raw, "\n"); $sep = ','; $best = substr_count($first, ','); foreach ([';', "\t"] as $c) if (substr_count($first, $c) > $best) { $sep = $c; $best = substr_count($first, $c); }
    $h = fopen('php://temp', 'r+'); fwrite($h, $raw); rewind($h); $hd = null; $rows = []; $ln = 0;
    while (($r = fgetcsv($h, 0, $sep, '"', '')) !== false) { $ln++; if ($r === [null] || (count($r) === 1 && trim((string)$r[0]) === '')) continue; $r = array_map(fn($x) => trim((string)$x), $r); if ($hd === null) { $hd = array_map('mb_strtolower', $r); continue; } $rows[] = ['line' => $ln, 'c' => $r]; }
    return [$hd ?? [], $rows];
}
function colx(array $hd, array $names): ?int { foreach ($names as $n) foreach ($hd as $i => $x) if ($x === $n) return $i; foreach ($names as $n) foreach ($hd as $i => $x) if (mb_strpos($x, $n) !== false) return $i; return null; }
function tmin(string $s): ?int { if (preg_match('/^(\d{1,2})[:.h](\d{2})\s*(am|pm)?$/i', trim($s), $m)) { $h = (int)$m[1]; $mi = (int)$m[2]; if (!empty($m[3])) { if ($h < 1 || $h > 12) return null; $h = $h % 12 + (strtolower($m[3]) === 'pm' ? 12 : 0); } return $h < 24 && $mi < 60 ? $h * 60 + $mi : null; } if (preg_match('/^(\d{1,2})\s*(am|pm)$/i', trim($s), $m)) { $h = (int)$m[1]; return $h >= 1 && $h <= 12 ? ($h % 12 + (strtolower($m[2]) === 'pm' ? 12 : 0)) * 60 : null; } return null; }
function hm(int $m): string { return sprintf('%02d:%02d', intdiv($m, 60) % 24, $m % 60); }
function dur(int $m): string { return intdiv($m, 60) . 'h' . sprintf('%02d', $m % 60); }
function num(string $s): ?float { $s = trim(str_replace([' ', '€', '$', '£'], '', $s)); if ($s === '') return null; if (preg_match('/^\((.+)\)$/', $s, $m)) $s = '-' . $m[1]; if (preg_match('/^-?\d{1,3}(?:\.\d{3})+,\d+$/', $s)) $s = str_replace(',', '.', str_replace('.', '', $s)); elseif (preg_match('/^-?\d{1,3}(?:,\d{3})+(?:\.\d+)?$/', $s)) $s = str_replace(',', '', $s); elseif (preg_match('/^-?\d+,\d{1,2}$/', $s)) $s = str_replace(',', '.', $s); return is_numeric($s) ? (float)$s : null; }
function money(float $v): string { return number_format($v, 2, '.', ','); }

// ---- roster -------------------------------------------------------------------------------------------------------------------------------------
if ($sub === 'roster') {
    [$hd, $rows] = read_csv($raw); $p = colx($hd, ['person', 'volunteer', 'name', 'benevole', 'bénévole', 'freiwillig', 'voluntari']); $d = colx($hd, ['date', 'day', 'jour', 'tag', 'giorno', 'fecha']); $s = colx($hd, ['start', 'from', 'début', 'beginn', 'inizio', 'inicio']); $e = colx($hd, ['end', 'to', 'fin', 'ende', 'fine']); $r = colx($hd, ['role', 'task', 'shift', 'rôle', 'rolle', 'ruolo', 'puesto']); $nd = colx($hd, ['needed', 'required', 'slots', 'besoin', 'benötigt', 'necessari']);
    if ($p === null || $d === null || $s === null || $e === null) fail('the roster needs the columns person, date, start and end (found: ' . implode(', ', $hd) . ')');
    $maxday = isset($opt['maxday']) ? (float)$opt['maxday'] : 8.0; $sh = []; $by = []; $groups = [];
    foreach ($rows as $row) { $c = $row['c']; $l = $row['line']; $dt = $c[$d] ?? ''; $a = tmin($c[$s] ?? ''); $b = tmin($c[$e] ?? '');
        if ($dt === '' || $a === null || $b === null) { $add('Error', $l, 'the date or times cannot be read (date "' . $dt . '", ' . ($c[$s] ?? '') . ' to ' . ($c[$e] ?? '') . ')'); continue; }
        if ($b <= $a) $b += 1440; if ($b - $a > 960) $add('Warning', $l, 'a shift of ' . dur($b - $a) . ': check the times');
        $role = $r !== null ? ($c[$r] ?? '') : ''; $who = $c[$p] ?? ''; $key = "$dt|$a|$b|" . mb_strtolower($role); $need = $nd !== null ? num($c[$nd] ?? '') : null;
        $groups[$key] ??= ['line' => $l, 'dt' => $dt, 'a' => $a, 'b' => $b, 'role' => $role, 'n' => 0, 'need' => null]; if ($need !== null) $groups[$key]['need'] = max((int)$need, (int)$groups[$key]['need']);
        if ($who === '') { $add('Error', $l, "uncovered shift: $dt " . hm($a) . '-' . hm($b) . ($role !== '' ? " ($role)" : '') . ' has nobody assigned'); continue; }
        $groups[$key]['n']++; $sh[] = ['who' => $who, 'dt' => $dt, 'a' => $a, 'b' => $b, 'l' => $l, 'role' => $role]; }
    $by = []; foreach ($sh as $x) $by[mb_strtolower($x['who'])][] = $x;
    foreach ($by as $who => $list) { usort($list, fn($x, $y) => [$x['dt'], $x['a']] <=> [$y['dt'], $y['a']]);
        for ($i = 1; $i < count($list); $i++) for ($j = 0; $j < $i; $j++) if ($list[$i]['dt'] === $list[$j]['dt'] && $list[$i]['a'] < $list[$j]['b'] && $list[$j]['a'] < $list[$i]['b']) { $add('Error', $list[$i]['l'], "double-booked: {$list[$i]['who']} on {$list[$i]['dt']} " . hm($list[$i]['a']) . '-' . hm($list[$i]['b']) . ' overlaps the shift on line ' . $list[$j]['l'] . ' (' . hm($list[$j]['a']) . '-' . hm($list[$j]['b']) . ')'); break; } }
    foreach ($groups as $g) if ($g['need'] !== null && $g['n'] > 0 && $g['n'] < $g['need']) $add('Error', $g['line'], "understaffed: {$g['dt']} " . hm($g['a']) . '-' . hm($g['b']) . ($g['role'] !== '' ? " ({$g['role']})" : '') . ": {$g['n']} of {$g['need']} needed");
    $o = "# Volunteer roster check: $name\n\n- Shifts listed: " . count($rows) . "; people: " . count($by) . "\n- A person with more than " . rtrim(rtrim(number_format($maxday, 1), '0'), '.') . " hours in one day is flagged (maxday=N to change)\n\n## Hours per person\n\n| Person | Shifts | Hours | Busiest day |\n|---|---|---|---|\n";
    $tot = 0; ksort($by); foreach ($by as $who => $list) { $m = 0; $day = []; foreach ($list as $x) { $m += $x['b'] - $x['a']; $day[$x['dt']] = ($day[$x['dt']] ?? 0) + $x['b'] - $x['a']; } arsort($day); $bd = array_key_first($day); $tot += $m; $o .= '| ' . $list[0]['who'] . ' | ' . count($list) . ' | ' . dur($m) . " | $bd " . dur($day[$bd]) . " |\n"; if ($day[$bd] > $maxday * 60) $add('Warning', $list[0]['l'], $list[0]['who'] . " works " . dur($day[$bd]) . " on $bd, more than $maxday hours"); }
    $o .= "\nTotal volunteer time: " . dur($tot) . ".\n"; finish($out, $o, $msgs, $nerr, $nwarn);
}

// ---- timeline -----------------------------------------------------------------------------------------------------------------------------------
if ($sub === 'timeline') {
    [$hd, $rows] = read_csv($raw); $s = colx($hd, ['start', 'from', 'time', 'début', 'beginn', 'inizio', 'inicio']); $e = colx($hd, ['end', 'to', 'fin', 'ende', 'fine']); $m = colx($hd, ['minutes', 'duration', 'durée', 'dauer', 'durata', 'duración', 'min']); $it = colx($hd, ['item', 'what', 'activity', 'title', 'segment', 'programme', 'program', 'description', 'name']);
    if ($s === null || ($e === null && $m === null)) fail('the run of show needs a start column and an end (or minutes) column (found: ' . implode(', ', $hd) . ')');
    $ev = []; foreach ($rows as $row) { $c = $row['c']; $l = $row['line']; $a = tmin($c[$s] ?? ''); if ($a === null) { $add('Error', $l, 'the start time "' . ($c[$s] ?? '') . '" cannot be read (use HH:MM)'); continue; }
        $b = $e !== null && ($c[$e] ?? '') !== '' ? tmin($c[$e]) : null; if ($b === null && $m !== null && ($d = num($c[$m] ?? '')) !== null) $b = $a + (int)$d; elseif ($b === null) { $add('Error', $l, 'no readable end time or minutes'); continue; }
        if ($e !== null && $b <= $a && $m === null) { $add('Error', $l, 'the end ' . hm($b) . ' is not after the start ' . hm($a)); continue; } if ($b <= $a) { $add('Error', $l, 'a length of zero or less'); continue; }
        $ev[] = ['l' => $l, 'a' => $a, 'b' => $b, 'it' => $it !== null ? ($c[$it] ?? '') : '']; }
    if (!$ev) fail('no readable rows found'); usort($ev, fn($x, $y) => [$x['a'], $x['b']] <=> [$y['a'], $y['b']]);
    $o = "# Run of show check: $name\n\n| Start | End | Length | Item |\n|---|---|---|---|\n"; $busy = 0; $cur = null; $gaps = 0;
    foreach ($ev as $i => $x) { $o .= '| ' . hm($x['a']) . ' | ' . hm($x['b']) . ' | ' . ($x['b'] - $x['a']) . ' min | ' . $x['it'] . " |\n";
        if ($cur !== null) { if ($x['a'] < $cur['b']) $add('Error', $x['l'], 'overlap: "' . $x['it'] . '" starts at ' . hm($x['a']) . ' before "' . $cur['it'] . '" (line ' . $cur['l'] . ') ends at ' . hm($cur['b']) . ' (' . ($cur['b'] - $x['a']) . ' min)');
            elseif ($x['a'] > $cur['b']) { $gaps += $x['a'] - $cur['b']; $add($x['a'] - $cur['b'] >= 15 ? 'Warning' : 'Note', $x['l'], 'gap of ' . ($x['a'] - $cur['b']) . ' min between ' . hm($cur['b']) . ' and ' . hm($x['a']) . ' (after "' . $cur['it'] . '")'); } }
        if ($cur === null || $x['b'] > $cur['b']) $cur = $x; $busy += $x['b'] - $x['a']; }
    $first = $ev[0]['a']; $last = max(array_column($ev, 'b')); $o .= "\n- First start " . hm($first) . ', last end ' . hm($last) . '; total length ' . dur($last - $first) . ' (' . ($last - $first) . " min)\n- Time with a segment running: counting each segment in full, $busy min; gaps: $gaps min\n";
    finish($out, $o, $msgs, $nerr, $nwarn);
}

// ---- budget -------------------------------------------------------------------------------------------------------------------------------------
if ($sub === 'budget') {
    [$hd, $rows] = read_csv($raw); $li = colx($hd, ['line', 'item', 'description', 'name', 'poste', 'posten', 'voce', 'concepto']); $bi = colx($hd, ['budget', 'planned', 'plan', 'prévu', 'geplant', 'preventivo', 'presupuesto']); $ai = colx($hd, ['actual', 'spent', 'réel', 'tatsächlich', 'ist', 'consuntivo', 'real', 'gasto']); $ci = colx($hd, ['category', 'catégorie', 'kategorie', 'categoria', 'categoría']);
    if ($li === null || $bi === null || $ai === null) fail('the budget needs the columns line, budget and actual (found: ' . implode(', ', $hd) . ')');
    $tb = 0; $ta = 0; $cat = []; $o = "# Budget against actual: $name\n\nVariance = actual minus budget: positive is overspent. A line more than 10% over is a warning. Arithmetic on your figures only.\n\n| Line | Budget | Actual | Variance | % |\n|---|---|---|---|---|\n"; $n = 0;
    foreach ($rows as $row) { $c = $row['c']; $l = $row['line']; $ln = $c[$li] ?? ''; if ($ln === '') { $add('Error', $l, 'a row without a line name'); continue; } $b = num($c[$bi] ?? ''); $a = num($c[$ai] ?? '');
        if ($b === null) { $add('Error', $l, "the budget \"" . ($c[$bi] ?? '') . "\" of \"$ln\" is not a number"); continue; } if ($b < 0) $add('Warning', $l, "a negative budget for \"$ln\"");
        if ($a === null) { if (($c[$ai] ?? '') !== '') { $add('Error', $l, "the actual \"" . $c[$ai] . "\" of \"$ln\" is not a number"); continue; } $add('Note', $l, "no actual yet for \"$ln\" (counted as 0 in the totals)"); $a = 0.0; }
        $n++; $tb += $b; $ta += $a; $v = $a - $b; $pc = $b != 0 ? round(100 * $v / $b, 1) . '%' : ($a != 0 ? 'n/a' : '0%'); $o .= "| $ln | " . money($b) . ' | ' . money($a) . ' | ' . ($v > 0 ? '+' : '') . money($v) . " | $pc |\n";
        if ($b == 0 && $a > 0) $add('Warning', $l, "\"$ln\" has no budget but " . money($a) . ' was spent'); elseif ($b > 0 && $a > $b * 1.1) $add('Warning', $l, "\"$ln\" is over budget by " . money($v) . ' (' . round(100 * $v / $b, 1) . '%)');
        if ($ci !== null && ($c[$ci] ?? '') !== '') { $k = $c[$ci]; $cat[$k][0] = ($cat[$k][0] ?? 0) + $b; $cat[$k][1] = ($cat[$k][1] ?? 0) + $a; } }
    if (!$n) fail('no budget lines found'); $tv = $ta - $tb; $o .= '| **Total** | ' . money($tb) . ' | ' . money($ta) . ' | ' . ($tv > 0 ? '+' : '') . money($tv) . ' | ' . ($tb != 0 ? round(100 * $tv / $tb, 1) . '%' : 'n/a') . " |\n";
    if ($cat) { $o .= "\n## By category\n\n| Category | Budget | Actual | Variance |\n|---|---|---|---|\n"; foreach ($cat as $k => [$b, $a]) $o .= "| $k | " . money($b) . ' | ' . money($a) . ' | ' . (($a - $b) > 0 ? '+' : '') . money($a - $b) . " |\n"; }
    if ($tv > 0) $add('Warning', 1, 'the total is over budget by ' . money($tv)); finish($out, $o, $msgs, $nerr, $nwarn);
}

// ---- grant --------------------------------------------------------------------------------------------------------------------------------------
$cf = $args[0] ?? ''; if ($cf === '' || preg_match('/^\w+=/', $cf)) fail('grant needs the checklist as the extra argument: a text file with one "Section | max words" per line', 2); $cl = load($cf); $req = [];
foreach (explode("\n", $cl) as $i => $l) { $l = trim($l); if ($l === '' || $l[0] === '#') continue; $l = preg_replace('/^[-*•]\s*(\[[ xX]\]\s*)?/u', '', $l); if (preg_match('/^(.+?)\s*[|:;]\s*(\d{1,6})(?:\s*words?)?\s*$/iu', $l, $m)) $req[] = [trim($m[1]), (int)$m[2], $i + 1]; elseif ($l !== '') $req[] = [$l, null, $i + 1]; }
if (!$req) fail('the checklist has no sections');
$secs = []; $curk = null; $lines = explode("\n", $raw); $heads = [];
foreach ($lines as $i => $l) { if (preg_match('/^\s*#{1,6}\s*(.+?)\s*#*\s*$/u', $l, $m)) { $curk = count($secs); $secs[] = ['t' => trim($m[1], ' *'), 'line' => $i + 1, 'txt' => '']; continue; } if ($curk !== null) $secs[$curk]['txt'] .= $l . "\n"; }
if (!$secs) fail('the draft has no headings (# Section): the sections cannot be told apart');
$wc = fn(string $t) => preg_match_all('/[\p{L}\p{N}][\p{L}\p{N}\'’-]*/u', $t); $o = "# Grant draft against the checklist: $name\n\nWords are counted in the text under each heading, up to the next heading. A section is found when a heading contains the checklist name (any case). The limits are the ones you typed from the funder's guidance: check them there.\n\n| Section | Words | Limit | Status |\n|---|---|---|---|\n"; $used = [];
foreach ($req as [$t, $lim, $cln]) { $found = null; foreach ($secs as $k => $s) if (mb_stripos($s['t'], $t) !== false || mb_stripos($t, $s['t']) !== false) { $found = $k; break; }
    if ($found === null) { $o .= "| $t | - | " . ($lim ?? '-') . " | MISSING |\n"; $add('Error', 1, "the draft has no section \"$t\""); continue; } $used[$found] = true; $s = $secs[$found]; $w = $wc($s['txt']);
    if ($w === 0) { $o .= "| $t | 0 | " . ($lim ?? '-') . " | EMPTY |\n"; $add('Error', $s['line'], "the section \"{$s['t']}\" is empty"); continue; }
    $st = 'ok'; if ($lim !== null && $w > $lim) { $st = 'OVER by ' . ($w - $lim); $add('Error', $s['line'], "\"{$s['t']}\" has $w words, " . ($w - $lim) . " over the limit of $lim"); } elseif ($lim !== null && $w < $lim * 0.3) { $st = 'short'; $add('Warning', $s['line'], "\"{$s['t']}\" has only $w of $lim allowed words: check it says enough"); }
    if (preg_match('/\b(TODO|TBD|XXX|\[insert|lorem ipsum)\b/iu', $s['txt'])) { $st .= ', placeholder'; $add('Warning', $s['line'], "\"{$s['t']}\" still contains a placeholder (TODO/TBD/...)"); }
    $o .= "| $t | $w | " . ($lim ?? '-') . " | $st |\n"; }
foreach ($secs as $k => $s) if (!isset($used[$k]) && $wc($s['txt']) > 0) $add('Note', $s['line'], "the draft section \"{$s['t']}\" is not on the checklist");
finish($out, $o, $msgs, $nerr, $nwarn);
