<?php
/**
 * Small generic helpers used across pages.
 */

function e(?string $s): string
{
    // ENT_SUBSTITUTE is REQUIRED: passing explicit flags overrides PHP 8.1's default set, and without it
    // htmlspecialchars() returns an EMPTY STRING for any input holding an invalid UTF-8 byte — silently
    // blanking the whole value (a raw server-log line, a name with a Latin-1 byte). Substitute U+FFFD
    // instead so the value still renders. Escaping behaviour is otherwise unchanged.
    return htmlspecialchars((string) $s, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}

function url(string $path = ''): string
{
    // Already an absolute (or protocol-relative) URL → return as-is; NEVER re-prefix the base (that produced the
    // ".../medmecs/https://.../medmecs/..." double-prefix bug when an absolute Referer was passed to redirect()).
    if ($path !== '' && preg_match('#^(https?:)?//#i', $path)) return $path;
    return rtrim(APP_BASE_URL, '/') . '/' . ltrim($path, '/');
}

function redirect(string $path): never
{
    header('Location: ' . url($path), true, 302);
    exit;
}

/** A SAME-ORIGIN, base-relative return URL derived from the Referer (for "back to the list" redirects), else
 *  $fallback. Strips scheme+host and the app base path so redirect()/url() re-add the base exactly once, and refuses
 *  an off-site Referer (open-redirect guard). Use instead of redirect($_SERVER['HTTP_REFERER']). */
function back_url(string $fallback = '/'): string
{
    $ref = trim((string) ($_SERVER['HTTP_REFERER'] ?? ''));
    if ($ref === '') return $fallback;
    $p = @parse_url($ref);
    if (!is_array($p)) return $fallback;
    $appHost = parse_url(APP_BASE_URL, PHP_URL_HOST);
    if (!empty($p['host']) && $appHost && strcasecmp($p['host'], (string) $appHost) !== 0) return $fallback;   // off-site → fallback
    $path = $p['path'] ?? '/';
    if (!empty($p['query'])) $path .= '?' . $p['query'];
    $basePath = rtrim((string) parse_url(APP_BASE_URL, PHP_URL_PATH), '/');
    if ($basePath !== '' && strncmp($path, $basePath . '/', strlen($basePath) + 1) === 0) $path = substr($path, strlen($basePath));
    // Must be a SINGLE-slash same-origin path. Reject protocol-relative ("//host") or backslash ("/\host") results —
    // otherwise url()'s absolute-URL passthrough would forward them off-site (open redirect).
    if ($path === '' || $path[0] !== '/' || strncmp($path, '//', 2) === 0 || strncmp($path, '/\\', 2) === 0) return $fallback;
    return $path;
}

/**
 * Per-page filter memory that lives for the LOGIN SESSION (so leaving a list and coming back keeps the
 * last-used filters) and resets on logout (the session is destroyed). No redirect: when a page is opened
 * with none of its filter params, the last-applied set is written back into $_GET so the page reads it
 * transparently. Contract for the caller's page:
 *   - the filter <form> carries a hidden <input name="_f" value="1"> so SUBMITTING (even with everything
 *     cleared) records the new set — that is how "I cleared all filters" is remembered vs a fresh visit;
 *   - the Clear link points at the page with &clear=1 so it FORGETS the remembered set.
 * $names = the GET keys that are genuine filters (NOT navigation like view/queue/status/tab/id/sort/dir).
 * Multi-value filters (name[]) are preserved as arrays. Call once, early, BEFORE reading the filters.
 */
function remember_filters(string $key, array $names): void
{
    start_session();
    $skey = '_flt_' . $key;
    if (isset($_GET['clear'])) { unset($_SESSION[$skey]); return; }          // Clear → forget
    // ONLY the filter form is authoritative (it submits _f=1 with every field, so it fully defines the set — incl.
    // "everything cleared"). Everything else — nav links, status cards, tabs, deep links — may carry SOME filter
    // params for its own purpose; those must NOT shrink the remembered set.
    if (isset($_GET['_f'])) { $_SESSION[$skey] = array_intersect_key($_GET, array_flip($names)); return; }
    // Non-form request → restore each remembered filter the request did not explicitly set (an explicit URL param
    // wins per key). This keeps the full filter set alive across navigation without a redirect.
    $saved = (!empty($_SESSION[$skey]) && is_array($_SESSION[$skey])) ? $_SESSION[$skey] : [];
    foreach ($saved as $k => $v) if (!isset($_GET[$k])) $_GET[$k] = $v;
}

function flash(string $key, string $message): void
{
    start_session();
    $_SESSION['_flash'][$key][] = $message;
}

/** @return string[] */
function flash_take(string $key): array
{
    start_session();
    $messages = $_SESSION['_flash'][$key] ?? [];
    unset($_SESSION['_flash'][$key]);
    return $messages;
}

function input(string $name, mixed $default = ''): mixed
{
    return $_POST[$name] ?? $_GET[$name] ?? $default;
}

function client_ip(): string
{
    return $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0';
}

/* ---- Date display: US MM/DD/YYYY everywhere a user sees a date ----
 * fmt_date()  -> 05/27/2026
 * fmt_dt()    -> 05/27/2026 14:30   (date + 24h time)
 * Both accept a DATE / DATETIME string, a unix timestamp, or null and never throw.
 * Internal uses (filenames, SQL values, JSON, cron) must keep their own date()
 * calls — these helpers are for human-facing output only. */
function fmt_date(mixed $v, string $empty = '—'): string
{
    if ($v === null) return $empty;
    $s = trim((string) $v);
    if ($s === '' || str_starts_with($s, '0000')) return $empty;
    $ts = is_numeric($s) ? (int) $s : strtotime($s);
    return $ts ? date('m/d/Y', $ts) : $empty;
}

/** Human-facing date+TIME. TZ-AWARE: records are STAMPED in Eastern (APP_TIMEZONE); this renders the moment in the
 *  viewer's chosen report timezone (Eastern by default, IST when they flip the toggle). Pure dates use fmt_date (no TZ). */
function fmt_dt(mixed $v, string $empty = '—'): string
{
    if ($v === null) return $empty;
    $s = trim((string) $v);
    if ($s === '' || str_starts_with($s, '0000')) return $empty;
    try {
        $dt = is_numeric($s) ? new DateTime('@' . (int) $s)                          // unix = absolute instant
                             : new DateTime($s, new DateTimeZone(db_session_tz()));  // DB string is in the session zone
        return $dt->setTimezone(new DateTimeZone(app_display_tz()))->format('m/d/Y H:i');
    } catch (Throwable $e) {
        $ts = is_numeric($s) ? (int) $s : strtotime($s);
        return $ts ? date('m/d/Y H:i', $ts) : $empty;
    }
}

/** The timezone the DB SESSION is pinned to (set in includes/db.php) — the zone MySQL emits TIMESTAMP strings in.
 *  Display helpers re-interpret DB values in THIS zone to recover the true instant, then render per-date DST. */
function db_session_tz(): string { return $GLOBALS['__medmecs_db_tz'] ?? APP_TIMEZONE; }

/** The viewer's chosen REPORT display timezone (a tz name for DateTimeZone). Default = Eastern (the stamp zone);
 *  a supervisor/manager may flip it to IST. Persisted per-user in users.report_tz, cached in the session. */
function app_display_tz(): string
{
    if (!isset($_SESSION['report_tz'])) {
        $_SESSION['report_tz'] = 'EST';
        try {
            if (!empty($_SESSION['uid'])) {
                $st = db()->prepare("SELECT report_tz FROM users WHERE id=?");
                $st->execute([(int) $_SESSION['uid']]);
                $v = $st->fetchColumn();
                if ($v === 'IST' || $v === 'EST') $_SESSION['report_tz'] = $v;
            }
        } catch (Throwable $e) { /* column not provisioned yet → default EST */ }
    }
    return ($_SESSION['report_tz'] ?? 'EST') === 'IST' ? 'Asia/Kolkata' : APP_TIMEZONE;
}

/** The viewer's report timezone as a short label for the UI ('EST' | 'IST'). Calls app_display_tz() first so the
 *  per-user preference is lazy-loaded into the session before the header renders (no first-load label/body mismatch). */
function report_tz_label(): string { app_display_tz(); return ($_SESSION['report_tz'] ?? 'EST') === 'IST' ? 'IST' : 'EST'; }

/** Only supervisors / managers (any vertical) + SysAdmin may flip the report timezone. */
function can_toggle_report_tz(): bool
{
    return is_sysadmin() || can('coding.supervise', 'ar.supervise', 'billing.supervise');
}

/** TZ-aware date/time formatter for EXPORTS (custom format; empty string default). Renders a stamped-Eastern moment
 *  in the viewer's chosen report timezone so downloads match the on-screen toggle. Use for TIMESTAMP columns only —
 *  NOT for pure dates (DOS/DOB), which never shift. */
function disp_dt(mixed $v, string $fmt = 'Y-m-d H:i', string $empty = ''): string
{
    if ($v === null) return $empty;
    $s = trim((string) $v);
    if ($s === '' || str_starts_with($s, '0000')) return $empty;
    try {
        $dt = is_numeric($s) ? new DateTime('@' . (int) $s) : new DateTime($s, new DateTimeZone(db_session_tz()));
        return $dt->setTimezone(new DateTimeZone(app_display_tz()))->format($fmt);
    } catch (Throwable $e) {
        $ts = is_numeric($s) ? (int) $s : strtotime($s);
        return $ts ? date($fmt, $ts) : $empty;
    }
}

/* ---- Password policy ---- */

function password_policy_errors(string $password, ?string $current = null): array
{
    $errs = [];
    if (strlen($password) < PASSWORD_MIN_LENGTH) {
        $errs[] = 'Must be at least ' . PASSWORD_MIN_LENGTH . ' characters.';
    }
    if (PASSWORD_REQUIRE_MIXED && !(preg_match('/[A-Z]/', $password) && preg_match('/[a-z]/', $password))) {
        $errs[] = 'Must include both upper and lower case letters.';
    }
    if (PASSWORD_REQUIRE_NUMBER && !preg_match('/[0-9]/', $password)) {
        $errs[] = 'Must include at least one digit.';
    }
    if (PASSWORD_REQUIRE_SYMBOL && !preg_match('/[^A-Za-z0-9]/', $password)) {
        $errs[] = 'Must include at least one symbol.';
    }
    if ($current !== null && hash_equals($current, $password)) {
        $errs[] = 'Must be different from the current password.';
    }
    return $errs;
}

/* ---- Rate limit (session+DB hybrid; v1 uses session/IP per page) ---- */

function rate_limit_check(string $key, int $maxAttempts, int $windowSeconds): bool
{
    start_session();
    $now = time();
    $bucket = $_SESSION['_rate'][$key] ?? ['count' => 0, 'resets_at' => $now + $windowSeconds];
    if ($now > $bucket['resets_at']) {
        $bucket = ['count' => 0, 'resets_at' => $now + $windowSeconds];
    }
    $bucket['count']++;
    $_SESSION['_rate'][$key] = $bucket;
    return $bucket['count'] <= $maxAttempts;
}

function rate_limit_reset(string $key): void
{
    start_session();
    unset($_SESSION['_rate'][$key]);
}

function rate_limit_remaining_seconds(string $key): int
{
    start_session();
    $bucket = $_SESSION['_rate'][$key] ?? null;
    return $bucket ? max(0, $bucket['resets_at'] - time()) : 0;
}
