<?php
// ─── Output / URLs ───
function e($s){ return htmlspecialchars((string)$s, ENT_QUOTES, 'UTF-8'); }
function redirect($url){ header('Location: ' . $url); exit; }
function base_url($p = ''){ $b = rtrim((string)config('base_url'), '/'); return $b . '/' . ltrim($p, '/'); }
function admin_url($p = ''){ return base_url('admin/' . ltrim($p, '/')); }
// CSS/JS URL with automatic cache-buster — browsers refetch whenever the file changes on disk
function asset_url($p){ $v = @filemtime(dirname(__DIR__) . '/' . ltrim($p, '/')) ?: 1; return base_url($p) . '?v=' . $v; }
function link_url($u){ if ($u === '' || $u === null) return base_url(); return $u[0] === '/' ? base_url($u) : $u; }
// Resolve a stored image/asset path to a browser URL (subfolder-safe; absolute URLs pass through).
function media_url($u){
  $u = trim((string) $u);
  if ($u === '') return '';
  if (preg_match('#^https?://#i', $u)) return $u;
  return base_url(ltrim($u, '/'));
}
// Admin-dashboard brand mark — used for the browser-tab favicon AND as the
// empty-avatar fallback. Prefers the theme's own logo (theme_admin_favicon),
// then an uploaded 'favicon' setting, then the shared ExpertAims mark, so each
// client's CMS tab and blank profile photos carry that client's own logo.
function admin_favicon_url(){
  if (function_exists('theme_admin_favicon')) {
    $f = trim((string) theme_admin_favicon());
    if ($f !== '') return $f;
  }
  $s = trim((string) setting('favicon', ''));
  if ($s !== '') return media_url($s);
  return base_url('assets/ea-mark.png');
}
// Validate an admin-supplied EXTERNAL link (blog/press "Source" field). Only
// absolute http/https URLs pass; anything else (javascript:, data:, mailto:,
// relative, or malformed) returns '' so it is never rendered or stored as a link.
function safe_ext_url($u){
  $u = trim((string) $u);
  if ($u === '') return '';
  if (!preg_match('#^https?://#i', $u)) return '';
  return filter_var($u, FILTER_VALIDATE_URL) ? $u : '';
}

// Ordered list of a page's hero banner SLIDES (multi-hero support). Each slide is
// ['img'=>path, 'eyebrow'=>'', 'heading'=>'', 'subtext'=>''] — the text fields are
// OPTIONAL per-slide overrides (blank ⇒ fall back to the page's hero_* fields at
// render time). Storage in pages.hero_images, newest-format first:
//   • JSON  '[{"img":"…","eyebrow":"…","heading":"…","subtext":"…"}, …]'  (per-slide text)
//   • legacy newline list of image paths (no per-slide text)
//   • empty ⇒ the single hero_image (so one-image pages behave exactly as before)
function hero_slides($p){
  $raw = trim((string) ($p['hero_images'] ?? ''));
  $slides = [];
  if ($raw !== '') {
    if ($raw[0] === '[') {                       // JSON per-slide format
      $arr = json_decode($raw, true);
      if (is_array($arr)) {
        foreach ($arr as $it) {
          if (is_array($it)) {
            $img = trim((string) ($it['img'] ?? ''));
            if ($img === '') continue;
            $slides[] = [
              'img'     => $img,
              'eyebrow' => trim((string) ($it['eyebrow'] ?? '')),
              'heading' => trim((string) ($it['heading'] ?? '')),
              'subtext' => trim((string) ($it['subtext'] ?? '')),
            ];
          } elseif (is_string($it) && trim($it) !== '') {
            $slides[] = ['img' => trim($it), 'eyebrow' => '', 'heading' => '', 'subtext' => ''];
          }
        }
      }
    }
    if (!$slides) {                              // legacy newline list of paths
      foreach (preg_split('/\r\n|\r|\n/', $raw) as $line) {
        $line = trim($line);
        if ($line !== '') $slides[] = ['img' => $line, 'eyebrow' => '', 'heading' => '', 'subtext' => ''];
      }
    }
  }
  if (!$slides) {
    $one = trim((string) ($p['hero_image'] ?? ''));
    if ($one !== '') $slides[] = ['img' => $one, 'eyebrow' => '', 'heading' => '', 'subtext' => ''];
  }
  return $slides;
}
// Just the ordered image paths (thumbnails, media-usage scans, counts).
function hero_images_list($p){
  return array_map(function ($s) { return $s['img']; }, hero_slides($p));
}

// Home-hero CTA buttons — pages.hero_ctas holds a small JSON list
//   [{"text":"Request Free Interview","url":"contact.html"}, …]
// (first button renders solid, the rest as glass/outline). Empty / malformed /
// pre-upgrade DBs fall back to the two brand-standard buttons so the hero is
// never left without a call to action.
function hero_ctas($p){
  $raw = trim((string) ($p['hero_ctas'] ?? ''));
  $out = [];
  if ($raw !== '' && $raw[0] === '[') {
    $arr = json_decode($raw, true);
    if (is_array($arr)) {
      foreach ($arr as $it) {
        if (!is_array($it)) continue;
        $t = trim((string) ($it['text'] ?? ''));
        $u = trim((string) ($it['url'] ?? ''));
        if ($t === '') continue;
        $out[] = ['text' => $t, 'url' => $u];
      }
    }
  }
  if (!$out) $out = [
    ['text' => 'Get a consultation', 'url' => 'contact.html'],
    ['text' => 'Our services',       'url' => 'services.html'],
  ];
  return $out;
}

// Fully-qualified URL (scheme + host) for social/share tags — og:image must be
// absolute for crawlers. Relative paths resolve against the configured site_url.
function abs_url($u){
  $u = trim((string) $u);
  if ($u === '' || preg_match('#^https?://#i', $u)) return $u;
  $rel  = media_url($u);   // base-aware site-relative path, e.g. /ntppa/uploads/x.jpg
  $site = trim((string) config('site_url'));
  if ($site !== '' && preg_match('#^(https?://[^/]+)#i', $site, $m)) return $m[1] . $rel;
  return $rel;             // no site_url configured — root-relative still works on-site
}

// ─── Security headers — shared baseline on every response; the admin calls
// security_headers(true) for the stricter set (frame-deny + CSP). ───
function security_headers($admin = false){
  if (headers_sent()) return;
  header('X-Content-Type-Options: nosniff');
  header('Referrer-Policy: strict-origin-when-cross-origin');
  header('Permissions-Policy: camera=(), microphone=(), geolocation=(), payment=()');
  if ($admin){
    header('X-Frame-Options: DENY');
    // Admin screens must always reflect the live database — never let a browser
    // or proxy serve a stale Pages list / nav after an add / delete / publish.
    header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0');
    header('Pragma: no-cache');
    // Allows the admin's own inline scripts/handlers + Google Fonts; blocks
    // everything else (no third-party scripts, no framing, no form hijack).
    header("Content-Security-Policy: default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data: https:; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'; object-src 'none'");
  } else {
    header('X-Frame-Options: SAMEORIGIN');
  }
}

// ─── Settings ───
function settings(){
  static $s = null;
  if ($s === null){ $s = []; foreach (db()->query('SELECT skey,svalue FROM settings') as $r){ $s[$r['skey']] = $r['svalue']; } }
  return $s;
}
function setting($k, $d = ''){ $s = settings(); return $s[$k] ?? $d; }

// ─── Client logo (Settings → Client logo) ───
// Reusable per client. Precedence for the sidebar site chip / brand marks:
//   client_logo setting → localized site logo (uploads/site/ntppa-logo.png)
//   → '' (caller then shows the initial fallback). Base-URL aware (admin runs
//   under /admin/); absolute URLs pass straight through via media_url().
function client_logo_url(){
  $c = trim((string) setting('client_logo', ''));
  if ($c !== '') return media_url($c);
  $fallback = 'uploads/site/ntppa-logo.png';
  if (@is_file(dirname(__DIR__) . '/' . $fallback)) return base_url($fallback);
  // No uploaded site logo → fall back to the theme's brand mark so the
  // "View live site" chip shows the client's own logo, never a bare letter.
  if (function_exists('admin_favicon_url')) return admin_favicon_url();
  return '';
}

// ─── Disposable / throwaway email domains (newsletter anti-spam) ───
// A small, high-signal blocklist — real families never sign up from these.
function email_domain_blocked($email){
  $at = strrchr((string) $email, '@');
  if ($at === false) return false;
  $dom = strtolower(ltrim($at, '@'));
  static $bad = ['mailinator.com','guerrillamail.com','guerrillamail.info','10minutemail.com',
    'tempmail.com','temp-mail.org','yopmail.com','trashmail.com','sharklasers.com','getnada.com',
    'dispostable.com','maildrop.cc','fakeinbox.com','throwawaymail.com','mailnesia.com',
    'discard.email','spam4.me','grr.la','mytemp.email','moakt.com','emailondeck.com'];
  return in_array($dom, $bad, true);
}

// ─── Login rate limiting — max 5 failed attempts per email+IP in 15 minutes. ───
// DB-backed (login_attempts table, self-created on first use) so it survives new
// sessions/cookies; degrades to the session counter if the table is unavailable.
function login_ip(){ return substr((string) ($_SERVER['REMOTE_ADDR'] ?? ''), 0, 45); }
function login_attempts_table(){
  static $ok = null;
  if ($ok !== null) return $ok;
  try {
    db()->exec("CREATE TABLE IF NOT EXISTS login_attempts (
      id INT AUTO_INCREMENT PRIMARY KEY,
      email VARCHAR(190) NOT NULL,
      ip VARCHAR(45) NOT NULL,
      attempted_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
      KEY idx_attempt (email, ip, attempted_at)
    ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
    $ok = true;
  } catch (\Throwable $e) { $ok = false; }
  return $ok;
}
function login_locked($email){
  if (login_attempts_table()){
    try {
      $st = db()->prepare('SELECT COUNT(*) FROM login_attempts WHERE email=? AND ip=? AND attempted_at > (NOW() - INTERVAL 900 SECOND)');
      $st->execute([mb_substr(trim((string) $email), 0, 190), login_ip()]);
      if ((int) $st->fetchColumn() >= 5) return true;
    } catch (\Throwable $e) { /* fall through to the session counter */ }
  }
  return !empty($_SESSION['lock_until']) && time() < (int) $_SESSION['lock_until'];
}
function login_fail($email){
  if (login_attempts_table()){
    try {
      db()->prepare('INSERT INTO login_attempts(email, ip) VALUES(?, ?)')
        ->execute([mb_substr(trim((string) $email), 0, 190), login_ip()]);
      db()->exec('DELETE FROM login_attempts WHERE attempted_at < (NOW() - INTERVAL 86400 SECOND)'); // sweep old rows
    } catch (\Throwable $e) { /* session fallback below still counts */ }
  }
  $_SESSION['fails'] = (int) ($_SESSION['fails'] ?? 0) + 1;
  if ($_SESSION['fails'] >= 5) $_SESSION['lock_until'] = time() + 900; // 15 min
  // Security monitor: log the attempt; escalate to a HIGH-severity (emailed)
  // event once the account locks — that's the real brute-force signal.
  if (function_exists('security_log')) {
    if ($_SESSION['fails'] >= 5) security_log('login_lockout', 3, 'admin login locked after ' . (int)$_SESSION['fails'] . ' fails');
    else security_log('login_fail', 1, 'failed admin login');
  }
  sleep(1); // throttle every failed attempt
}
function login_reset($email){
  if (login_attempts_table()){
    try { db()->prepare('DELETE FROM login_attempts WHERE email=? AND ip=?')->execute([mb_substr(trim((string) $email), 0, 190), login_ip()]); } catch (\Throwable $e) {}
  }
  unset($_SESSION['fails'], $_SESSION['lock_until']);
}

// ─── Auth ───
function current_user(){ return $_SESSION['user'] ?? null; }
function require_login(){ if (!current_user()) redirect(admin_url('?p=login')); }
function attempt_login($email, $pass){
  $st = db()->prepare('SELECT * FROM users WHERE email = ?');
  $st->execute([trim($email)]);
  $u = $st->fetch();
  if ($u && password_verify($pass, $u['password_hash'])){
    session_regenerate_id(true); // prevent session fixation
    $_SESSION['user'] = [
      'id' => $u['id'], 'name' => $u['name'], 'email' => $u['email'], 'role' => $u['role'],
      // avatar/perms columns may not exist on older installs — degrade gracefully
      'avatar' => (string) ($u['avatar'] ?? ''),
      'perms' => (isset($u['perms']) && $u['perms'] !== '' ? (json_decode($u['perms'], true) ?: []) : []),
    ];
    return true;
  }
  return false;
}

// ─── Permissions (admin = everything; editor = limited by the perms JSON) ───
// Capabilities: pages, blog, media, settings, users. Editors may also be limited
// to specific page slugs via perms['scope'] ('all' or a comma-separated slug list).
function user_perms(){
  $u = current_user(); if (!$u) return [];
  if (($u['role'] ?? '') === 'admin')
    return ['pages'=>1,'blog'=>1,'media'=>1,'settings'=>1,'users'=>1,'scope'=>'all'];
  return $u['perms'] ?? [];
}
function user_can($cap){ $p = user_perms(); return !empty($p[$cap]); }
function require_cap($cap){
  if (!user_can($cap)){ flash('You don’t have permission to open that area.'); redirect(admin_url('?p=dashboard')); }
}
function user_can_page($slug){
  if (!user_can('pages')) return false;
  $p = user_perms(); $sc = $p['scope'] ?? 'all';
  if ($sc === 'all' || $sc === '') return true;
  return in_array($slug, array_filter(array_map('trim', explode(',', $sc))), true);
}

// ─── Rich-text safety net — strips scripts/styles/handlers from client content.
// NOT applied to 'html' (Custom HTML) sections, which stay admin-trusted verbatim. ───
function clean_richtext($html){
  $html = (string) $html;
  $html = preg_replace('#<script\b[^>]*>.*?</script>#is', '', $html);
  $html = preg_replace('#<style\b[^>]*>.*?</style>#is', '', $html);
  $html = preg_replace('#\son[a-z]+\s*=\s*("[^"]*"|\'[^\']*\'|[^\s>]+)#i', '', $html); // on* handlers
  $html = preg_replace('#(href|src)\s*=\s*(["\'])\s*javascript:[^"\']*\2#i', '$1="#"', $html);
  return $html;
}

// ═══ Public-form protection (contact + newsletter) ═══════════════════════
// Every public form ships four independent guards, all validated SERVER-side:
//   1. honeypot  — hidden "company_website" input; bots fill it, humans can't
//   2. captcha   — a simple sum question bound to the visitor's session
//   3. token     — the session CSRF token must round-trip
//   4. rate limit — max N submissions per IP per hour (DB table, self-creating)
// plus a spam heuristic (link count / keyword list) that FLAGS rather than
// drops, so a real enquiry can never be lost to a false positive.

function client_ip(){ return substr((string) ($_SERVER['REMOTE_ADDR'] ?? ''), 0, 45); }

// Shared sum-captcha for the public forms. One pair per session; regenerated
// after every successful submission so an answer can't be replayed.
function captcha_pair(){
  if (empty($_SESSION['cap_a']) || empty($_SESSION['cap_b'])){
    $_SESSION['cap_a'] = random_int(2, 9);
    $_SESSION['cap_b'] = random_int(1, 9);
  }
  return [(int) $_SESSION['cap_a'], (int) $_SESSION['cap_b']];
}
function captcha_question(){ $p = captcha_pair(); return $p[0] . ' + ' . $p[1]; }
function captcha_ok($answer){
  $p = captcha_pair();
  $want = $p[0] + $p[1];
  return $want > 0 && is_numeric(trim((string) $answer)) && (int) trim((string) $answer) === $want;
}
function captcha_rotate(){ unset($_SESSION['cap_a'], $_SESSION['cap_b']); }

// The block injected wherever a section body contains <!--CAPTCHA-->
// (see cms_markers in app/render.php) — a quick-check sum field, the
// honeypot and the session token, styled by .form-captcha in style.css.
function captcha_fields_html(){
  return '<div class="form-captcha">'
    . '<div class="form-group" id="grp-captcha"><label for="captchaSum">Quick check, what is ' . e(captcha_question()) . '? <span style="color:#DC2626">*</span></label>'
    . '<input type="text" id="captchaSum" name="sum" inputmode="numeric" autocomplete="off" placeholder="Your answer" aria-describedby="err-captcha">'
    . '<span class="field-error" id="err-captcha">Please answer the quick math check.</span></div>'
    . '<input type="text" name="company_website" value="" tabindex="-1" autocomplete="off" aria-hidden="true" class="hp-field">'
    . '<input type="hidden" name="_token" value="' . e(csrf_token()) . '">'
    . '</div>';
}

/**
 * Email the careers inbox when a job application is captured (?capture=application).
 * The application is always stored in the DB (Admin → Applications, where the CV
 * is downloadable) — this is a best-effort NOTIFICATION on top, so the team hears
 * about it by email too. From: a no-reply on the site's own domain (SPF-safe);
 * Reply-To: the applicant. mail() delivery depends on the server MTA (cPanel has
 * one; plain `php -S` locally usually does not) — silent on failure.
 */
function ea_notify_application(array $a){
  $to = trim((string) setting('careers_email', 'careers@expertaims.com'));
  if ($to === '' || !filter_var($to, FILTER_VALIDATE_EMAIL)) return false;
  $site  = (string) setting('site_name', 'ExpertAims');
  $name  = trim(($a['first'] ?? '') . ' ' . ($a['last'] ?? ''));
  $role  = ($a['job'] ?? '') !== '' ? $a['job'] : 'Open application';
  $subj  = 'New job application: ' . $role . ($name !== '' ? ' — ' . $name : '');
  $files = array_filter([$a['file1'] ?? '', $a['file2'] ?? '', $a['file3'] ?? '']);
  $admin = rtrim((string) config('site_url'), '/') . '/admin/?p=applications';
  $lines = [
    'A new job application arrived on ' . $site . '.', '',
    'Role:       ' . $role,
    'Name:       ' . ($name !== '' ? $name : '—'),
    'Email:      ' . ($a['email'] ?? '—'),
    'Phone:      ' . (($a['phone'] ?? '') !== '' ? $a['phone'] : '—'),
    'Portfolio:  ' . (($a['video'] ?? '') !== '' ? $a['video'] : '—'),
    'From page:  ' . ($a['page'] ?? '—'),
    'Attached:   ' . ($files ? implode(', ', $files) : 'none'),
  ];
  if (!empty($a['spam'])) $lines[] = '(Flagged as possible spam — review in the inbox.)';
  $lines[] = '';
  $lines[] = 'Message';
  $lines[] = '-------';
  $lines[] = (($a['message'] ?? '') !== '' ? $a['message'] : '—');
  $lines[] = '';
  $lines[] = 'Open it in the CMS (download the CV here): ' . $admin;
  $host = preg_replace('/[^a-z0-9.\-]/i', '', (string) (parse_url((string) config('site_url'), PHP_URL_HOST) ?: 'expertaims.com'));
  $from = 'no-reply@' . $host;
  $reply = filter_var($a['email'] ?? '', FILTER_VALIDATE_EMAIL) ? $a['email'] : $from;
  $headers = 'From: ' . mb_encode_mimeheader($site) . ' <' . $from . '>' . "\r\n"
           . 'Reply-To: ' . $reply . "\r\n"
           . 'Content-Type: text/plain; charset=UTF-8' . "\r\n"
           . 'X-Mailer: AimsHub-CMS';
  try { return (bool) @mail($to, '=?UTF-8?B?' . base64_encode($subj) . '?=', implode("\n", $lines), $headers); }
  catch (\Throwable $e) { return false; }
}

// DB-backed submission rate limit (defaults: 5 per IP per hour per form kind).
// Self-creating table; if the DB is unavailable it falls back to a session
// counter so protection never silently disappears.
function form_throttle_table(){
  static $ok = null;
  if ($ok !== null) return $ok;
  try {
    db()->exec("CREATE TABLE IF NOT EXISTS form_throttle (
      id INT AUTO_INCREMENT PRIMARY KEY,
      kind VARCHAR(20) NOT NULL,
      ip VARCHAR(45) NOT NULL,
      created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
      KEY idx_throttle (kind, ip, created_at)
    ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
    $ok = true;
  } catch (\Throwable $e) { $ok = false; }
  return $ok;
}
function form_throttled($kind, $limit = 5, $window = 3600){
  if (form_throttle_table()){
    try {
      db()->exec("DELETE FROM form_throttle WHERE created_at < (NOW() - INTERVAL 86400 SECOND)"); // sweep old rows
      $st = db()->prepare('SELECT COUNT(*) FROM form_throttle WHERE kind=? AND ip=? AND created_at > (NOW() - INTERVAL ' . (int) $window . ' SECOND)');
      $st->execute([$kind, client_ip()]);
      if ((int) $st->fetchColumn() >= $limit) {
        if (function_exists('security_log')) security_log('rate_limit', 1, 'form throttle: ' . $kind);
        return true;
      }
      db()->prepare('INSERT INTO form_throttle(kind, ip) VALUES(?, ?)')->execute([$kind, client_ip()]);
      return false;
    } catch (\Throwable $e) { /* fall through to the session counter */ }
  }
  $k = 'ft_' . preg_replace('/[^a-z]/', '', $kind);
  $now = time();
  $hits = array_filter((array) ($_SESSION[$k] ?? []), function ($t) use ($now, $window){ return $t > $now - $window; });
  if (count($hits) >= $limit) { $_SESSION[$k] = array_values($hits); return true; }
  $hits[] = $now; $_SESSION[$k] = array_values($hits);
  return false;
}

// Heuristic spam detector — FLAGS a submission (is_spam=1) so it lands in the
// inbox marked as spam instead of being lost. Three signals: 3+ links,
// a known junk keyword, or raw bbcode/anchor markup in the message.
function looks_spammy($text){
  $t = mb_strtolower((string) $text);
  if (preg_match_all('#https?://#', $t) >= 3) return true;
  foreach (['viagra','cialis','casino','loan approval','crypto invest','bitcoin profit','forex signal',
            'seo service','seo expert','backlinks','guest post','porn','xxx','escort','payday loan',
            'work from home','earn $','make money fast','weight loss pill','betting site'] as $w){
    if (strpos($t, $w) !== false) return true;
  }
  if (preg_match('#\[(?:url|link)=|<a\s+href#i', $t)) return true;
  return false;
}

// ─── Custom menu items (Admin → page editor → "Show in menu") ───
// Published pages whose menu_parent is set are injected into the shared nav
// by assets/common.js (dropdowns + mobile drawer). Grouped: main | programs | about.
function nav_extra_items(){
  $out = ['main' => [], 'services' => [], 'company' => []];
  try {
    $rows = db()->query("SELECT slug, title, nav_label, menu_parent FROM pages
                         WHERE status='published' AND menu_parent IS NOT NULL AND menu_parent <> ''
                         ORDER BY sort, id")->fetchAll();
  } catch (\Throwable $e) { return $out; } // column not migrated yet — no custom items
  // slugs the static menu already links to — never duplicated
  $builtin = ['home','services','projects','about','clients','innovation','news','careers','companies','products','contact','hosting'];
  foreach ($rows as $r){
    if (in_array($r['slug'], $builtin, true)) continue;
    $g = in_array($r['menu_parent'], ['main','services','company'], true) ? $r['menu_parent'] : 'main';
    $out[$g][] = ['t' => (string) ($r['nav_label'] ?: $r['title']), 'u' => $r['slug'] . '.html'];
  }
  return $out;
}

// ═══ Recovery-key provisioning (admin/reset.php) ══════════════════════════
// The self-service password reset needs ADMIN_RESET_KEY in .env. Instead of
// asking the client to hand-edit files, the CMS provisions one automatically:
// on first-admin creation (setup.php), on any admin sign-in while the key is
// missing, and on demand from Team & Access → "Regenerate". The new key is
// written to .env and shown to the admin exactly ONCE — never logged.
function recovery_env_file(){
  // same precedence as the loader in app/db.php (APP_ENV_FILE can relocate .env)
  require_once __DIR__ . '/env.php';
  return env('APP_ENV_FILE') ?: dirname(__DIR__) . '/.env';
}
function recovery_key_configured(){
  try { config(); } catch (\Throwable $e) { /* .env already loaded via bootstrap */ }
  return trim((string) (env('ADMIN_RESET_KEY') ?? config('admin_reset_key') ?? '')) !== '';
}
// Write/replace ADMIN_RESET_KEY in .env. If .env doesn't exist it is created
// MINIMAL (key only) — deliberately NOT copied from .env.example, because the
// example's empty DB_* placeholders would override app/config.php and break
// the database connection. Returns true when the key is persisted.
function write_recovery_key($key){
  if (!preg_match('/^[a-f0-9]{64}$/', (string) $key)) return false;
  $envFile = recovery_env_file();
  try {
    if (!file_exists($envFile)) {
      $seed = "# ── NTPPA CMS — secrets & environment config ──\n"
            . "# Created automatically to store the admin recovery key.\n"
            . "# Add DB overrides here if you ever need them (see .env.example).\n";
      if (@file_put_contents($envFile, $seed, LOCK_EX) === false) return false;
      @chmod($envFile, 0600);
    }
    if (!is_writable($envFile)) return false;
    $c = (string) @file_get_contents($envFile);
    if (preg_match('/^\s*ADMIN_RESET_KEY\s*=/m', $c)) {
      $c = preg_replace('/^\s*ADMIN_RESET_KEY\s*=.*$/m', 'ADMIN_RESET_KEY=' . $key, $c, 1);
    } else {
      $c = rtrim($c, "\r\n") . "\n\n# Secret recovery key for the admin password reset (admin/reset.php).\n"
         . "# Added automatically — regenerate any time from Team & Access.\nADMIN_RESET_KEY=" . $key . "\n";
    }
    if (@file_put_contents($envFile, $c, LOCK_EX) === false) return false;
    putenv('ADMIN_RESET_KEY=' . $key);   // effective immediately, no reload needed
    $_ENV['ADMIN_RESET_KEY'] = $key;
    return true;
  } catch (\Throwable $e) { return false; }
}
// Generate a key, persist it, and stage the ONE-TIME on-screen reveal.
// Returns [key, written]. The reveal is consumed by the next admin page load.
function provision_recovery_key(){
  $key = bin2hex(random_bytes(32));
  $written = write_recovery_key($key);
  $_SESSION['reveal_reset_key'] = ['key' => $key, 'written' => $written];
  return [$key, $written];
}

// ─── CSRF ───
function csrf_token(){ if (empty($_SESSION['csrf'])) $_SESSION['csrf'] = bin2hex(random_bytes(16)); return $_SESSION['csrf']; }
function csrf_field(){ return '<input type="hidden" name="_csrf" value="' . csrf_token() . '">'; }
function csrf_check(){ if (!hash_equals($_SESSION['csrf'] ?? '', $_POST['_csrf'] ?? '')){ http_response_code(419); exit('Session expired — go back and try again.'); } }

// ─── Flash ───
function flash($msg = null){
  if ($msg !== null){ $_SESSION['flash'] = $msg; return null; }
  $m = $_SESSION['flash'] ?? null; unset($_SESSION['flash']); return $m;
}

function slugify($t){ $t = strtolower(trim($t)); $t = preg_replace('/[^a-z0-9]+/', '-', $t); return trim($t, '-') ?: 'section'; }

// ─── Data access ───
function all_pages(){ return db()->query('SELECT * FROM pages ORDER BY sort, id')->fetchAll(); }
function find_page($slug, $publishedOnly = false){
  $q = 'SELECT * FROM pages WHERE slug = ?' . ($publishedOnly ? " AND status='published'" : '');
  $st = db()->prepare($q); $st->execute([$slug]); return $st->fetch();
}
function page_by_id($id){ $st = db()->prepare('SELECT * FROM pages WHERE id = ?'); $st->execute([$id]); return $st->fetch(); }
function page_sections($pid, $visibleOnly = false){
  $q = 'SELECT * FROM sections WHERE page_id = ?' . ($visibleOnly ? ' AND visible=1' : '') . ' ORDER BY sort, id';
  $st = db()->prepare($q); $st->execute([$pid]); return $st->fetchAll();
}
function all_posts($publishedOnly = false){
  $q = 'SELECT * FROM posts' . ($publishedOnly ? " WHERE status='published'" : '') . ' ORDER BY published_at DESC, id DESC';
  return db()->query($q)->fetchAll();
}
function find_post($slug, $publishedOnly = false){
  $q = 'SELECT * FROM posts WHERE slug = ?' . ($publishedOnly ? " AND status='published'" : '');
  $st = db()->prepare($q); $st->execute([$slug]); return $st->fetch();
}
function count_rows($table){ return (int) db()->query("SELECT COUNT(*) FROM `$table`")->fetchColumn(); }

// ─── First-party visit counter (feeds the admin Dashboard analytics). ───
// Privacy-friendly by design: no cookies, no IPs, no user agents stored —
// just one row per day + path with a hit count. The public front controller
// calls track_visit() for every successfully rendered page; the admin is
// never counted (it lives on a different entry point). '~visit' is a
// reserved path: it gets one hit per browsing session per day, so the
// dashboard can chart "site visits" alongside raw page views.
function visits_table_ddl(){
  return 'CREATE TABLE IF NOT EXISTS visits (
    day DATE NOT NULL,
    path VARCHAR(160) NOT NULL,
    hits INT UNSIGNED NOT NULL DEFAULT 1,
    PRIMARY KEY (day, path)
  ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4';
}
function visits_meta_ddl(){
  return 'CREATE TABLE IF NOT EXISTS visits_meta (
    day DATE NOT NULL,
    kind VARCHAR(12) NOT NULL,
    k VARCHAR(120) NOT NULL,
    hits INT UNSIGNED NOT NULL DEFAULT 1,
    PRIMARY KEY (day, kind, k)
  ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4';
}

// Free country lookup (country.is — no key, no cost); cached in the session so it
// runs at most once per visitor session. Never blocks the page for long.
function visitor_country(){
  if (isset($_SESSION['cc'])) return $_SESSION['cc'];
  $cc = strtoupper(trim((string) ($_SERVER['HTTP_CF_IPCOUNTRY'] ?? '')));   // free if behind Cloudflare
  if (!preg_match('/^[A-Z]{2}$/', $cc)) {
    $ip = (string) ($_SERVER['REMOTE_ADDR'] ?? '');
    $cc = 'ZZ';
    if ($ip !== '' && !preg_match('/^(10\.|127\.|192\.168\.|172\.(1[6-9]|2[0-9]|3[01])\.|::1|fc|fd)/', $ip) && function_exists('curl_init')) {
      $ch = curl_init('https://api.country.is/' . rawurlencode($ip));
      curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT_MS => 700, CURLOPT_CONNECTTIMEOUT_MS => 400]);
      $j = json_decode((string) curl_exec($ch), true); curl_close($ch);
      if (is_array($j) && preg_match('/^[A-Za-z]{2}$/', (string) ($j['country'] ?? ''))) $cc = strtoupper($j['country']);
    }
  }
  return $_SESSION['cc'] = $cc;
}

function visitor_device($ua){
  if (preg_match('/ipad|tablet/i', $ua)) return 'tablet';
  if (preg_match('/mobi|android|iphone/i', $ua)) return 'mobile';
  return 'desktop';
}

function track_visit($path){
  if (PHP_SAPI === 'cli') return;
  $m = $_SERVER['REQUEST_METHOD'] ?? 'GET';
  if ($m !== 'GET' && $m !== 'HEAD') return;
  // Best-effort bot filter: an empty UA or a known crawler/tool signature is not a visitor.
  $ua = (string) ($_SERVER['HTTP_USER_AGENT'] ?? '');
  if ($ua === '' || preg_match('/bot|crawl|spider|slurp|preview|facebookexternalhit|whatsapp|telegram|headless|lighthouse|pagespeed|pingdom|uptime|monitor|statuscake|curl|wget|python|java|okhttp|go-http|libwww|httpclient|scrapy|scanner|semrush|ahrefs|mj12|dotbot|petal|bytespider|gptbot|claudebot|ccbot|censys|zgrab|masscan|nmap|nessus/i', $ua)) return;
  $path = mb_substr(trim((string) $path), 0, 160);
  if ($path === '') $path = 'home';
  $today = date('Y-m-d');   // site timezone (set in bootstrap)
  try {
    $ins = db()->prepare('INSERT INTO visits(day,path,hits) VALUES(?,?,1) ON DUPLICATE KEY UPDATE hits=hits+1');
    try { $ins->execute([$today, $path]); }
    catch (\Throwable $e) { db()->exec(visits_table_ddl()); $ins->execute([$today, $path]); }  // self-install on first hit
    if (($_SESSION['visit_day'] ?? '') !== $today) {   // one "site visit" per session per day
      $_SESSION['visit_day'] = $today;
      $ins->execute([$today, '~visit']);
      // Region / device / referrer — aggregated counts only, once per session (no IPs stored).
      try {
        $mi = db()->prepare('INSERT INTO visits_meta(day,kind,k,hits) VALUES(?,?,?,1) ON DUPLICATE KEY UPDATE hits=hits+1');
        try { $mi->execute([$today, 'country', visitor_country()]); }
        catch (\Throwable $e2) { db()->exec(visits_meta_ddl()); $mi->execute([$today, 'country', visitor_country()]); }
        $mi->execute([$today, 'device', visitor_device($ua)]);
        $refHost = strtolower((string) parse_url((string) ($_SERVER['HTTP_REFERER'] ?? ''), PHP_URL_HOST));
        $ownHost = strtolower((string) ($_SERVER['HTTP_HOST'] ?? ''));
        if ($refHost !== '' && $refHost !== $ownHost) $mi->execute([$today, 'ref', mb_substr(preg_replace('/^www\./', '', $refHost), 0, 120)]);
      } catch (\Throwable $e) { /* enrichment is best-effort */ }
    }
  } catch (\Throwable $e) { /* analytics must never break the public site */ }
}

// ─── "2 hours ago" style timestamps (site timezone) for the dashboard feed. ───
function rel_time($ts){
  $t = is_numeric($ts) ? (int) $ts : strtotime((string) $ts);
  if (!$t) return '—';
  $d = time() - $t;
  if ($d < 0) return date('M j, Y', $t);
  if ($d < 60) return 'just now';
  if ($d < 3600)   { $n = (int) floor($d / 60);    return $n . ' minute' . ($n === 1 ? '' : 's') . ' ago'; }
  if ($d < 86400)  { $n = (int) floor($d / 3600);  return $n . ' hour'   . ($n === 1 ? '' : 's') . ' ago'; }
  if ($d < 604800) { $n = (int) floor($d / 86400); return $n === 1 ? 'yesterday' : $n . ' days ago'; }
  return date('M j, Y', $t);
}

// ─── Feature modules — the per-client switchboard ("super dashboard") ────────
// One shared ExpertAims CMS engine powers every client; what each client's CMS
// exposes is decided per install by admins under Settings → Features. Flags
// live in the `features` setting (JSON map). Keys missing from the stored map
// fall back to each feature's default, so existing installs keep their current
// behaviour until someone flips a switch.
function feature_registry(){
  return [
    // key         => [label,                       what switching it on adds,                                default]
    'blog'          => ['Blog / Press posts',       'Posts screen in the CMS + blog/press pages on the site',  1],
    'popup'         => ['Homepage popup',           'Popup manager screen + the popup on the site',            1],
    'applications'  => ['Job applications',         'Applications inbox + career "Apply" form capture',        1],
    'visits'        => ['Visit analytics',          'Page-visit tracking + the dashboard traffic panel',       1],
    'quote_forms'   => ['Quotation request forms',  'Quote Form sections + quotation bands on product pages',  0],
    'news'          => ['News Stories',             'Page-based news stories screen + automatic news listing', 0],
    'certs'         => ['Certificates',             'Certificates screen + certificate badge/vault sections',  0],
    'sponsors'      => ['Sponsors / partners',      'Sponsors screen + scrolling partner-logo sections',       0],
  ];
}
function features(){
  static $f = null;
  if ($f !== null) return $f;
  $stored = json_decode((string) setting('features', ''), true) ?: [];
  // Before the `features` setting is seeded (first admin login), fall back to the
  // theme's declared defaults so the public site reflects the client's intended
  // feature set immediately — not the generic registry defaults.
  $themeDef = function_exists('theme_default_features') ? theme_default_features() : [];
  $f = [];
  foreach (feature_registry() as $k => $def) {
    if (isset($stored[$k]))            $f[$k] = !empty($stored[$k]);
    elseif (array_key_exists($k, $themeDef)) $f[$k] = !empty($themeDef[$k]);
    else                                $f[$k] = !empty($def[2]);
  }
  return $f;
}
function feature_on($k){ $f = features(); return !empty($f[$k]); }

// ─── ExpertAims HQ: signed fleet-status endpoint ─────────────────────────────
// Each client's index.php calls handle_status_api() early. HQ authenticates
// with X-HQ-Token: hash_hmac('sha256', 'hq-status', app_key) — app_key is the
// per-install secret already in app/config.php, so nothing new to provision.
define('CMS_CORE_BUILD', 'core-2.0');
function handle_status_api(){
  if (($_GET['api'] ?? '') !== 'status') return;
  header('Content-Type: application/json');
  $tok = (string) ($_SERVER['HTTP_X_HQ_TOKEN'] ?? ($_GET['token'] ?? ''));
  $want = hash_hmac('sha256', 'hq-status', (string) config('app_key'));
  if (!hash_equals($want, $tok)) { http_response_code(403); echo '{"error":"forbidden"}'; exit; }
  $out = ['ok' => true, 'core' => CMS_CORE_BUILD, 'build' => defined('CMS_BUILD') ? CMS_BUILD : null,
          'site' => (string) setting('site_name'), 'php' => PHP_VERSION, 'time' => date('c'),
          'features' => features()];
  foreach (['pages' => 'pages', 'sections' => 'sections', 'media' => 'media'] as $k => $tb) {
    try { $out[$k] = count_rows($tb); } catch (\Throwable $e) { $out[$k] = null; }
  }
  try { $out['enquiries_unread'] = (int) db()->query("SELECT COUNT(*) FROM submissions WHERE is_read=0")->fetchColumn(); }
  catch (\Throwable $e) { $out['enquiries_unread'] = null; }
  $out['last_backup'] = (string) setting('last_backup_at', '');
  // scope=analytics → 30-day series + breakdowns for the HQ dashboard
  if (($_GET['scope'] ?? '') === 'analytics') {
    $from = date('Y-m-d', strtotime('-29 days'));
    $days = []; for ($i = 29; $i >= 0; $i--) $days[date('Y-m-d', strtotime("-$i days"))] = ['views' => 0, 'visits' => 0];
    $pages = [];
    try {
      $st = db()->prepare('SELECT day, path, hits FROM visits WHERE day >= ?'); $st->execute([$from]);
      foreach ($st as $r) {
        $d = (string) $r['day']; $h = (int) $r['hits'];
        if (!isset($days[$d])) continue;
        if ($r['path'] === '~visit') $days[$d]['visits'] += $h;
        else { $days[$d]['views'] += $h; $pages[$r['path']] = ($pages[$r['path']] ?? 0) + $h; }
      }
    } catch (\Throwable $e) {}
    arsort($pages);
    $meta = ['country' => [], 'device' => [], 'ref' => []];
    try {
      $st = db()->prepare('SELECT kind, k, SUM(hits) h FROM visits_meta WHERE day >= ? GROUP BY kind, k'); $st->execute([$from]);
      foreach ($st as $r) { if (isset($meta[$r['kind']])) $meta[$r['kind']][$r['k']] = (int) $r['h']; }
    } catch (\Throwable $e) {}
    foreach ($meta as &$mm) arsort($mm); unset($mm);
    $out['analytics'] = ['days' => $days, 'pages' => array_slice($pages, 0, 12, true),
                         'countries' => array_slice($meta['country'], 0, 12, true),
                         'devices' => $meta['device'], 'referrers' => array_slice($meta['ref'], 0, 10, true)];
  }
  echo json_encode($out); exit;
}

// ─── ExpertAims HQ: signed fleet COMMAND endpoint (mutating; POST-only) ───────
// Companion to handle_status_api(). HQ signs each command with a TIMESTAMPED
// HMAC of app_key, so a captured request cannot be replayed outside a 5-minute
// window:  sig = hash_hmac('sha256', "hq-cmd:<cmd>:<ts>", app_key)
// Called from index.php right after handle_status_api().
function handle_command_api(){
  if (($_GET['api'] ?? '') !== 'command') return;
  header('Content-Type: application/json');
  if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST') { http_response_code(405); echo '{"error":"post_required"}'; exit; }
  $cmd = (string) ($_POST['cmd'] ?? '');
  $ts  = (int) ($_POST['ts'] ?? 0);
  $sig = (string) ($_POST['sig'] ?? '');
  $want = hash_hmac('sha256', 'hq-cmd:' . $cmd . ':' . $ts, (string) config('app_key'));
  if ($sig === '' || !hash_equals($want, $sig) || abs(time() - $ts) > 300) {
    if (function_exists('security_log')) security_log('hq_cmd_denied', 2, 'rejected HQ command: ' . substr($cmd, 0, 40));
    http_response_code(403); echo '{"error":"forbidden"}'; exit;
  }
  if ($cmd === 'reset_admin') {
    $creds = [];
    try {
      $admins = db()->query("SELECT id, name, email FROM users WHERE role='admin' ORDER BY id")->fetchAll(PDO::FETCH_ASSOC);
      $up = db()->prepare('UPDATE users SET password_hash = ? WHERE id = ?');
      foreach ($admins as $u) {
        $pw = hq_gen_password();
        $up->execute([password_hash($pw, PASSWORD_DEFAULT), (int) $u['id']]);
        $creds[] = ['name' => (string) $u['name'], 'email' => (string) $u['email'], 'password' => $pw];
      }
    } catch (\Throwable $e) {
      http_response_code(500); echo json_encode(['ok' => false, 'error' => 'db_error']); exit;
    }
    if (function_exists('security_log')) security_log('admin_reset_hq', 2, 'HQ reset ' . count($creds) . ' admin password(s)');
    echo json_encode(['ok' => true, 'reset' => count($creds), 'site' => (string) setting('site_name'), 'credentials' => $creds]);
    exit;
  }
  http_response_code(400); echo '{"error":"unknown_command"}'; exit;
}

// Strong, ambiguity-free random password (mixed case + digits; no 0/O/1/l/I).
function hq_gen_password($len = 16){
  $a = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789';
  $out = ''; $n = strlen($a) - 1;
  for ($i = 0; $i < $len; $i++) $out .= $a[random_int(0, $n)];
  return $out;
}

// ─── Certificate verification ───
function find_certificate($cert_id){
  $cert_id = mb_substr(trim((string) $cert_id), 0, 50);
  if ($cert_id === '') return null;
  try {
    $st = db()->prepare('SELECT * FROM certificates WHERE certificate_id = ? AND status = ?');
    $st->execute([$cert_id, 'published']);
    $r = $st->fetch(PDO::FETCH_ASSOC);
    return $r ?: null;
  } catch (\Throwable $e) { return null; }
}

function all_certificates(){
  try {
    $st = db()->query('SELECT * FROM certificates WHERE status = ? ORDER BY issued_date DESC', PDO::FETCH_ASSOC);
    $st->execute(['published']);
    return $st->fetchAll(PDO::FETCH_ASSOC);
  } catch (\Throwable $e) { return []; }
}

// ─── Certificate generation helpers ───
function generate_cert_id(){
  $year = (int) date('Y');
  try {
    $st = db()->prepare('SELECT MAX(CAST(SUBSTRING_INDEX(certificate_id, "-", -1) AS UNSIGNED)) as max_num FROM certificates WHERE certificate_id LIKE ? AND issued_date >= ?');
    $st->execute(['EA-IN-' . $year . '-%', $year . '-01-01']);
    $result = $st->fetch(PDO::FETCH_ASSOC);
    $next_num = ((int)($result['max_num'] ?? 0)) + 1;
    return 'EA-IN-' . $year . '-' . str_pad($next_num, 4, '0', STR_PAD_LEFT);
  } catch (\Throwable $e) {
    return 'EA-IN-' . $year . '-0001';
  }
}

function format_date_for_certificate($date_str){
  try {
    $d = new DateTime($date_str);
    return $d->format('d F Y');
  } catch (\Throwable $e) {
    return $date_str;
  }
}

// Shared <datalist> of common countries for certificate forms (free-text input
// with autocomplete — interns come from many countries, any value is accepted).
function cert_country_datalist(){
  $countries = ['India','Kuwait','Saudi Arabia','United Arab Emirates','Qatar','Bahrain','Oman',
    'Malaysia','Singapore','United States','United Kingdom','Canada','Australia','Germany','France',
    'Netherlands','Ireland','Nigeria','Kenya','Egypt','South Africa','Bangladesh','Pakistan','Sri Lanka',
    'Nepal','Philippines','Indonesia','China','Japan','Turkey','Jordan','Lebanon','Ghana'];
  $out = '<datalist id="country-list">';
  foreach ($countries as $c) $out .= '<option value="' . e($c) . '">';
  return $out . '</datalist>';
}

function generate_qr_url($cert_id){
  // QR codes MUST be absolute to be scannable. Prefer configured base_url,
  // then site_url setting, then the live request host, then a safe default.
  $base = rtrim((string) config('base_url'), '/');
  if ($base === '' || !preg_match('#^https?://#i', $base)) {
    $site = rtrim((string) setting('site_url', ''), '/');
    if ($site !== '' && preg_match('#^https?://#i', $site)) {
      $base = $site . ($base !== '' ? '/' . ltrim($base, '/') : '');
    } elseif (!empty($_SERVER['HTTP_HOST'])) {
      $scheme = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http';
      $base = $scheme . '://' . $_SERVER['HTTP_HOST'] . ($base !== '' ? '/' . ltrim($base, '/') : '');
    } else {
      $base = 'https://clients.expertaims.in/eac';
    }
  }
  return rtrim($base, '/') . '/verify?id=' . urlencode((string) $cert_id);
}
