<?php
// ============================================================
// includes/functions.php  –  Auth + Helpers
// ============================================================

require_once __DIR__ . '/../config/database.php';

// ── Session bootstrap ────────────────────────────────────────
function startSession(): void {
    if (session_status() === PHP_SESSION_NONE) {
        session_name(SESSION_NAME);
        session_set_cookie_params([
            'lifetime' => SESSION_TIMEOUT,
            'path'     => '/',
            'secure'   => false,   // set true in production with HTTPS
            'httponly' => true,
            'samesite' => 'Strict',
        ]);
        session_start();
    }
}

// ── Authentication ───────────────────────────────────────────
function login(string $username, string $password): bool {
    $db   = Database::getInstance();
    $user = $db->fetchOne(
        "SELECT u.*, e.first_name, e.last_name, e.employee_id AS emp_code
           FROM users u
           LEFT JOIN employees e ON e.id = u.employee_id
          WHERE u.username = ? AND u.is_active = 1",
        [$username]
    );
    if (!$user || !password_verify($password, $user['password_hash'])) {
        return false;
    }

    // Licensing check (07 Jul 2026): the ORGANIZATION's subscription
    // status is what gates login now (a company is just a sub-unit
    // inside an organization) — except the vendor account itself.
    if ($user['role'] !== 'vendor') {
        $company = $db->fetchOne("SELECT * FROM companies WHERE id=?", [$user['company_id']]);
        $org     = $company ? $db->fetchOne("SELECT * FROM organizations WHERE id=?", [$company['organization_id']]) : null;
        if (!$org || $org['status'] !== 'active') {
            $_SESSION['_login_error'] = 'Your organization\'s access is currently suspended. Please contact your provider.';
            return false;
        }
    }

    $_SESSION['user_id']             = $user['id'];
    $_SESSION['username']            = $user['username'];
    $_SESSION['role']                = $user['role'];
    $_SESSION['company_id']          = $user['company_id'];
    $_SESSION['organization_id']     = $company['organization_id'] ?? null;
    $_SESSION['emp_id']              = $user['employee_id'];
    $_SESSION['emp_code']            = $user['emp_code'] ?? '';
    $_SESSION['full_name']           = trim(($user['first_name'] ?? '') . ' ' . ($user['last_name'] ?? ''));
    $_SESSION['login_time']          = time();
    $_SESSION['must_change_password'] = (bool)($user['must_change_password'] ?? false);

    $db->execute("UPDATE users SET last_login = NOW() WHERE id = ?", [$user['id']]);
    return true;
}

function logout(): void {
    startSession();
    session_unset();
    session_destroy();
    header('Location: ' . BASE_URL . '/index.php');
    exit;
}

function isLoggedIn(): bool {
    startSession();
    if (empty($_SESSION['user_id'])) return false;
    if (time() - ($_SESSION['login_time'] ?? 0) > SESSION_TIMEOUT) {
        logout();
    }
    $_SESSION['login_time'] = time();
    return true;
}

function requireLogin(): void {
    if (!isLoggedIn()) {
        header('Location: ' . BASE_URL . '/index.php?msg=session_expired');
        exit;
    }
    // Force password change if flagged (skip on the change-password page itself)
    $currentPage = basename($_SERVER['PHP_SELF'] ?? '');
    if (!empty($_SESSION['must_change_password']) && $currentPage !== 'change_password.php') {
        header('Location: ' . BASE_URL . '/change_password.php');
        exit;
    }
}

function currentUser(): array {
    return [
        'id'                  => $_SESSION['user_id']              ?? 0,
        'username'            => $_SESSION['username']             ?? '',
        'role'                => $_SESSION['role']                 ?? 'employee',
        'company_id'          => $_SESSION['company_id']           ?? null,
        'organization_id'     => $_SESSION['organization_id']      ?? null,
        'emp_id'              => $_SESSION['emp_id']               ?? null,
        'emp_code'            => $_SESSION['emp_code']             ?? '',
        'name'                => $_SESSION['full_name']            ?? 'User',
        'must_change_password'=> $_SESSION['must_change_password'] ?? false,
    ];
}

// ── Licensing helpers ────────────────────────────────────────
// "Used" = users that can actually log in (is_active=1), counted across
// EVERY company that belongs to the organization (07 Jul 2026: license
// is sold per-organization, not per-company — an organization's own
// admin can create multiple companies under it, but they all share the
// one pool of purchased seats). Deactivating someone (swap) frees a
// slot immediately, same as before.
function companyId(): ?int {
    return $_SESSION['company_id'] ?? null;
}

function organizationId(): ?int {
    // FIX (08 Jul 2026): if the session was created before this feature
    // was deployed (or anything else left organization_id unset), this
    // used to just return null and cause a fatal error downstream
    // wherever it was passed into getLicenseUsage()/hasLicenseCapacity().
    // Now it self-heals: look it up fresh from the DB via company_id and
    // cache it back into the session, instead of staying null forever.
    if (isset($_SESSION['organization_id']) && $_SESSION['organization_id']) {
        return (int)$_SESSION['organization_id'];
    }
    $companyId = $_SESSION['company_id'] ?? null;
    if (!$companyId) return null;
    $db = Database::getInstance();
    try {
        $org = $db->fetchValue("SELECT organization_id FROM companies WHERE id=?", [$companyId]);
    } catch (Throwable $e) {
        // FIX (08 Jul 2026): if migration_organizations.sql hasn't been
        // run yet, companies.organization_id doesn't exist yet and this
        // query throws — don't let that crash the whole page, just report
        // "no organization" so callers fall back to their zero/empty state.
        error_log('organizationId() failed - has migration_organizations.sql been run? ' . $e->getMessage());
        return null;
    }
    if ($org) { $_SESSION['organization_id'] = (int)$org; return (int)$org; }
    return null;
}

function getLicenseUsage(Database $db, ?int $organizationId): array {
    // FIX: guard against null/0 (e.g. a session with no organization_id
    // resolvable at all) instead of throwing a fatal TypeError — just
    // report zero usage against a zero limit rather than crashing the page.
    if (!$organizationId) {
        return ['used' => 0, 'limit' => 0, 'status' => 'active'];
    }
    $org  = $db->fetchOne("SELECT * FROM organizations WHERE id=?", [$organizationId]);
    $used = (int)$db->fetchValue(
        "SELECT COUNT(*) FROM users u JOIN companies c ON c.id=u.company_id
          WHERE c.organization_id=? AND u.is_active=1",
        [$organizationId]
    );
    return [
        'used'   => $used,
        'limit'  => (int)($org['licensed_users'] ?? 0),
        'status' => $org['status'] ?? 'active',
    ];
}

function hasLicenseCapacity(Database $db, ?int $organizationId): bool {
    if (!$organizationId) return false;
    $u = getLicenseUsage($db, $organizationId);
    return $u['used'] < $u['limit'];
}

// ── Leave approval deduction, with automatic LOP overflow ──────
// Called when a leave application is APPROVED (not when applied —
// applying is always allowed even if balance looks short, so the
// employee doesn't get blocked from submitting; the actual balance
// check/split happens here, at approval time, using whatever the
// real balance is by then).
//
// Deducts up to what's actually available from the requested leave
// type; anything beyond that is NOT allowed to go negative — instead
// it's recorded as LOP days on muster_summary for the month the
// leave ends in (same column the Late Arrival cascade already uses),
// so "balance exhausted" leave just becomes partly-paid, partly-LOP
// instead of silently going negative or being blocked outright.
//
// Returns ['deducted' => float, 'lop' => float] so the caller can
// show/flash an accurate message.
function applyLeaveApprovalDeduction(Database $db, int $employeeId, int $leaveTypeId, float $days, string $toDate): array {
    $year = (int)date('Y', strtotime($toDate));
    $bal = $db->fetchOne("SELECT allocated,used FROM employee_leave_balance WHERE employee_id=? AND leave_type_id=? AND year=?", [$employeeId, $leaveTypeId, $year]);
    $available = $bal ? max(0.0, (float)$bal['allocated'] - (float)$bal['used']) : 0.0;

    $deduct = min($days, $available);
    $overflow = round($days - $deduct, 2);

    if ($deduct > 0) {
        $db->execute(
            "INSERT INTO employee_leave_balance (employee_id,leave_type_id,year,allocated,used) VALUES (?,?,?,0,?)
             ON DUPLICATE KEY UPDATE used = used + VALUES(used)",
            [$employeeId, $leaveTypeId, $year, $deduct]
        );
    }

    if ($overflow > 0) {
        $month = (int)date('n', strtotime($toDate));
        $existing = $db->fetchOne("SELECT id FROM muster_summary WHERE employee_id=? AND month=? AND year=?", [$employeeId, $month, $year]);
        if ($existing) {
            $db->execute("UPDATE muster_summary SET lop_days = lop_days + ? WHERE id=?", [$overflow, $existing['id']]);
        } else {
            $db->execute("INSERT INTO muster_summary (employee_id,month,year,lop_days) VALUES (?,?,?,?)", [$employeeId, $month, $year, $overflow]);
        }
        notify($db, $employeeId, 'Leave Balance Exceeded',
            "Your approved leave used up your full balance — $overflow day(s) beyond that were marked as LOP (Loss of Pay).", 'leave');
    }

    return ['deducted' => $deduct, 'lop' => $overflow];
}

// ── Feature Flags (per-organization feature gating) ────────────
// Lets the vendor turn a specific feature on for ONE organization
// (e.g. "Company A" asked for a new module) WITHOUT it appearing for
// any other organization — the opposite of the product-update banner,
// which is deliberately broadcast to everyone. Managed from the vendor
// console (modules/companies/index.php, "Feature Flags" card).
//
// Table shape (see migration_feature_flags.sql):
//   feature_flags         - the catalog: one row per feature that
//                            exists, with a default (on/off) for
//                            organizations that don't have an explicit
//                            row yet.
//   organization_features - the per-organization override: which
//                            organization has which feature_key on/off.
//
// Requires migration_feature_flags.sql to have been run.
function orgHasFeature(Database $db, ?int $organizationId, string $featureKey): bool {
    if (!$organizationId) return false;
    try {
        $row = $db->fetchOne(
            "SELECT is_enabled FROM organization_features WHERE organization_id=? AND feature_key=?",
            [$organizationId, $featureKey]
        );
        if ($row !== null) {
            return (bool)$row['is_enabled'];
        }
        // No explicit row for this organization yet — fall back to the
        // feature's own default (usually 0/off, since the whole point
        // of a flag is normally "off for everyone except who I pick").
        $default = $db->fetchValue("SELECT is_default_enabled FROM feature_flags WHERE feature_key=?", [$featureKey]);
        return (bool)$default;
    } catch (Throwable $e) {
        // Fail closed (feature hidden) rather than crash the page if the
        // migration hasn't been run yet.
        error_log('orgHasFeature() failed - has migration_feature_flags.sql been run? ' . $e->getMessage());
        return false;
    }
}

// Convenience wrapper: checks the feature against the CURRENT logged-in
// user's own organization. Use this in module files / includes/header.php
// to decide whether to show a menu link, a button, a whole page, etc.
//   if (hasFeature('new_payroll_widget')) { ... show it ... }
function hasFeature(string $featureKey): bool {
    static $db = null;
    if ($db === null) { $db = Database::getInstance(); }
    return orgHasFeature($db, organizationId(), $featureKey);
}

// Blocks the whole page for organizations that don't have this feature —
// same style/usage as requireRole(). Put it at the top of a module file,
// right after requireLogin()/requireRole():
//   requireFeature('new_payroll_widget');
function requireFeature(string $featureKey): void {
    requireLogin();
    if (!hasFeature($featureKey)) {
        http_response_code(403);
        die(renderError('Feature Not Available', 'This feature is not enabled for your organization. Contact your provider to request access.'));
    }
}

// Returns every company_id that belongs to the current user's organization
// (used to scope any query on `employees` / anything joined off it, so
// Organization A can never see Organization B's data). Vendor role gets
// an empty array back on purpose — vendor screens don't list employee
// data directly, they only manage organizations/licenses.
function orgCompanyIds(Database $db): array {
    $orgId = organizationId();
    if (!$orgId) return [];
    return array_column($db->fetchAll("SELECT id FROM companies WHERE organization_id=?", [$orgId]), 'id');
}

// Builds a safe "column IN (?,?,...)" fragment + matching bound values for
// the current organization's companies. Returns ['', []] (matches nothing)
// if somehow there are no companies, so a missing scope fails closed, not open.
function orgScopeClause(Database $db, string $column): array {
    $ids = orgCompanyIds($db);
    if (!$ids) return ["$column = -1", []];
    return ["$column IN (" . implode(',', array_fill(0, count($ids), '?')) . ")", $ids];
}

function hasRole(string ...$roles): bool {
    return in_array($_SESSION['role'] ?? '', $roles, true);
}

function requireRole(string ...$roles): void {
    requireLogin();
    if (!hasRole(...$roles)) {
        http_response_code(403);
        die(renderError('Access Denied', 'You do not have permission to view this page.'));
    }
}

// ── Input sanitisation ───────────────────────────────────────
function clean(mixed $val): string {
    return htmlspecialchars(strip_tags(trim((string)$val)), ENT_QUOTES, 'UTF-8');
}

function cleanInt(mixed $val): int {
    return (int) filter_var($val, FILTER_SANITIZE_NUMBER_INT);
}

function cleanFloat(mixed $val): float {
    return (float) filter_var($val, FILTER_SANITIZE_NUMBER_FLOAT, FILTER_FLAG_ALLOW_FRACTION);
}

function post(string $key, mixed $default = ''): string {
    return clean($_POST[$key] ?? $default);
}

function get(string $key, mixed $default = ''): string {
    return clean($_GET[$key] ?? $default);
}

function postInt(string $key, int $default = 0): int {
    return cleanInt($_POST[$key] ?? $default);
}

// ── CSRF ─────────────────────────────────────────────────────
function csrfToken(): string {
    if (empty($_SESSION['csrf_token'])) {
        $_SESSION['csrf_token'] = bin2hex(random_bytes(32));
    }
    return $_SESSION['csrf_token'];
}

function csrfField(): string {
    return '<input type="hidden" name="csrf_token" value="' . csrfToken() . '">';
}

function verifyCsrf(): void {
    if (($_POST['csrf_token'] ?? '') !== ($_SESSION['csrf_token'] ?? '')) {
        die(renderError('CSRF Error', 'Invalid form submission. Please go back and try again.'));
    }
}

// ── Flash messages ───────────────────────────────────────────
function setFlash(string $type, string $msg): void {
    $_SESSION['flash'] = ['type' => $type, 'msg' => $msg];
}

function getFlash(): ?array {
    $f = $_SESSION['flash'] ?? null;
    unset($_SESSION['flash']);
    return $f;
}

function flashHtml(): string {
    $f = getFlash();
    if (!$f) return '';
    $cls = match($f['type']) {
        'success' => 'alert-success',
        'error'   => 'alert-danger',
        'warning' => 'alert-warning',
        default   => 'alert-info',
    };
    return "<div class='alert $cls alert-dismissible fade show' role='alert'>
              {$f['msg']}
              <button type='button' class='btn-close' data-bs-dismiss='alert'></button>
            </div>";
}

// ── Formatting helpers ───────────────────────────────────────
function money(float $amt): string {
    return '₹' . number_format($amt, 2, '.', ',');
}

function fmtDate(?string $d, string $fmt = 'd M Y'): string {
    if (!$d) return '—';
    return date($fmt, strtotime($d));
}

function fmtDateTime(?string $dt): string {
    if (!$dt) return '—';
    return date('d M Y, h:i A', strtotime($dt));
}

function calcAge(?string $dob): int {
    if (!$dob) return 0;
    return (int) date_diff(new DateTime($dob), new DateTime())->y;
}

function employeeCode(): string {
    $db  = Database::getInstance();
    $max = $db->fetchValue("SELECT MAX(CAST(SUBSTRING(employee_id,5) AS UNSIGNED)) FROM employees");
    $next = ($max ?? 0) + 1;
    return 'EMP-' . str_pad($next, 4, '0', STR_PAD_LEFT);
}

function yearsOfService(string $doj): float {
    $diff = date_diff(new DateTime($doj), new DateTime());
    return round($diff->y + $diff->m / 12, 2);
}

// ── File upload ──────────────────────────────────────────────
function uploadFile(array $file, string $subdir, array $allowedTypes = []): string|false {
    if ($file['error'] !== UPLOAD_ERR_OK) return false;
    if ($file['size'] > MAX_FILE_SIZE) return false;
    $mime = mime_content_type($file['tmp_name']);
    if ($allowedTypes && !in_array($mime, $allowedTypes)) return false;
    $ext     = pathinfo($file['name'], PATHINFO_EXTENSION);
    $name    = uniqid('', true) . '.' . $ext;
    $destDir = UPLOAD_PATH . $subdir . '/';
    if (!is_dir($destDir)) mkdir($destDir, 0755, true);
    if (!move_uploaded_file($file['tmp_name'], $destDir . $name)) return false;
    return $subdir . '/' . $name;
}

// ── Pagination ───────────────────────────────────────────────
function paginate(int $total, int $perPage, int $current): array {
    $pages = (int) ceil($total / $perPage);
    return [
        'total'   => $total,
        'pages'   => $pages,
        'current' => $current,
        'offset'  => ($current - 1) * $perPage,
        'limit'   => $perPage,
    ];
}

function paginationHtml(array $p, string $url): string {
    if ($p['pages'] <= 1) return '';
    $html = "<nav><ul class='pagination pagination-sm mb-0'>";
    for ($i = 1; $i <= $p['pages']; $i++) {
        $active = $i === $p['current'] ? 'active' : '';
        $html  .= "<li class='page-item $active'><a class='page-link' href='$url&page=$i'>$i</a></li>";
    }
    return $html . "</ul></nav>";
}

// ── Audit log ────────────────────────────────────────────────
function auditLog(string $action, string $module, int $recordId,
                  ?array $old = null, ?array $new = null): void {
    $db = Database::getInstance();
    $db->execute(
        "INSERT INTO audit_log (user_id, action, module, record_id, old_data, new_data, ip_address)
         VALUES (?,?,?,?,?,?,?)",
        [
            currentUser()['id'],
            $action, $module, $recordId,
            $old ? json_encode($old) : null,
            $new  ? json_encode($new)  : null,
            $_SERVER['REMOTE_ADDR'] ?? '',
        ]
    );
}

// ── Status badges ────────────────────────────────────────────
function statusBadge(string $status): string {
    $map = [
        'active'    => 'success', 'approved'  => 'success', 'present'    => 'success',
        'completed' => 'success', 'paid'       => 'success', 'filed'      => 'success',
        'pending'   => 'warning', 'processing' => 'warning', 'draft'      => 'warning',
        'probation' => 'warning', 'submitted'  => 'info',
        'inactive'  => 'secondary','rejected'  => 'danger',  'absent'     => 'danger',
        'terminated'=> 'danger',  'failed'     => 'danger',
    ];
    $cls = $map[strtolower($status)] ?? 'secondary';
    return "<span class='badge bg-$cls'>" . ucfirst(str_replace('_', ' ', $status)) . "</span>";
}

// ── Error page ───────────────────────────────────────────────
function renderError(string $title, string $msg): string {
    return "<!DOCTYPE html><html><head><title>$title</title>
            <link rel='stylesheet' href='" . BASE_URL . "/assets/css/bootstrap.min.css'>
            </head><body class='bg-light'><div class='container mt-5'>
            <div class='alert alert-danger'><h4>$title</h4><p>$msg</p>
            <a href='javascript:history.back()' class='btn btn-sm btn-secondary'>Go Back</a>
            </div></div></body></html>";
}

// ── JSON response (for AJAX) ─────────────────────────────────
function jsonResponse(bool $success, string $message, array $data = []): void {
    header('Content-Type: application/json');
    echo json_encode(['success' => $success, 'message' => $message, 'data' => $data]);
    exit;
}

// ── Month name ───────────────────────────────────────────────
function monthName(int $m): string {
    return date('F', mktime(0, 0, 0, $m, 1));
}

// ── Holiday → Attendance auto-marking ───────────────────────────
// When a holiday is added (single add or bulk upload), every active
// employee's attendance for that date should read "Holiday" instead of
// being blank/absent. This writes an actual 'PH' row into `muster` for
// each active employee who doesn't already have an attendance record for
// that date — it never overwrites an existing entry (Present, Leave, On
// Duty, Week Off, etc. all stay exactly as they are).
//
// Because both the web reports (modules/reports/monthly_summary.php,
// modules/attendance/muster.php, ...) and the mobile app's
// attendance_history API (api/v1/index.php) read from this same `muster`
// table, marking it here is enough to make Holiday show up correctly in
// both places — no separate mobile-side change needed.
//
// $locationId: pass the holiday's location_id to scope to employees at
// that location only; pass null for an all-locations holiday.
// Returns the number of employees newly marked as Holiday.
function markHolidayAttendance(Database $db, string $date, ?int $locationId = null): int {
    [$orgClause, $orgParams] = orgScopeClause($db, 'company_id');
    $locClause = $locationId ? 'AND location_id=?' : '';
    $params = array_merge([$date], $orgParams);
    if ($locationId) $params[] = $locationId;

    return $db->execute(
        "INSERT IGNORE INTO muster (employee_id, muster_date, status)
         SELECT id, ?, 'PH' FROM employees WHERE status='active' AND $orgClause $locClause",
        $params
    );
}

// ── Notifications (bell #2 – personal notifications) ───────────────────
// FIX (30 Jul 2026): these previously only existed inside api/v1/index.php,
// so the mobile app created notification rows on apply/approve/reject but
// the WEB app never did — the bell dropdown had nothing to show for anyone
// working from the browser. Moved here so every module can call notify()
// too. Wrapped in try/catch so a notification failure can never break the
// actual apply/approve action — notifications are a side effect only.
//
// $employeeId targets a specific employee. $userId (new) targets a login
// account directly — used for superadmin accounts that have no linked
// employee record, since the table needs *something* non-null to attach
// the notification to.
function notify(Database $db, ?int $employeeId, string $title, string $message, string $type = 'general', ?int $relatedId = null, ?int $userId = null): void {
    if (!$employeeId && !$userId) return;
    try {
        $db->insertRow('notifications', [
            'employee_id' => $employeeId ?: null,
            'user_id'     => $userId ?: null,
            'title'       => $title,
            'message'     => $message,
            'type'        => $type,
            'ref_id'      => $relatedId,
        ]);
    } catch (Throwable $e) {
        error_log('notify() failed: ' . $e->getMessage());
    }
}

function getManagerId(Database $db, int $employeeId): ?int {
    $row = $db->fetchOne("SELECT reporting_manager_id FROM employees WHERE id=?", [$employeeId]);
    return ($row && $row['reporting_manager_id']) ? (int)$row['reporting_manager_id'] : null;
}

// Notifies the employee's direct reporting manager. If they have none set
// (i.e. they report directly to "Management"), notifies every HR/Admin/
// Superadmin *employee-linked* user instead, so the request never gets
// silently stuck. (Superadmins get a copy regardless — see
// notifyAllSuperadmins(), called separately alongside this.)
// Returns every company_id that belongs to the same organization as the
// given employee (usually just their own company, but an organization
// can have several companies under it — see orgCompanyIds()). Used to
// scope management/superadmin broadcasts so Company A's data can never
// reach Company B's admins, even though both are on the same install.
function orgCompanyIdsForEmployee(Database $db, int $employeeId): array {
    $companyId = $db->fetchValue("SELECT company_id FROM employees WHERE id=?", [$employeeId]);
    if (!$companyId) return [];
    $orgId = $db->fetchValue("SELECT organization_id FROM companies WHERE id=?", [$companyId]);
    if (!$orgId) return [(int)$companyId]; // no org grouping set up — scope to just this company
    return array_map('intval', array_column($db->fetchAll("SELECT id FROM companies WHERE organization_id=?", [$orgId]), 'id'));
}

function notifyReportingHead(Database $db, int $employeeId, string $title, string $message, string $type, int $relatedId): void {
    $mgrId = getManagerId($db, $employeeId);
    if ($mgrId) {
        notify($db, $mgrId, $title, $message, $type, $relatedId);
        return;
    }
    // FIX (30 Jul 2026): this used to notify HR/Admin/Superadmin across
    // EVERY company on the install with no org scoping at all — Company
    // A's leave request would page Company B's HR too. Now scoped to
    // only companies in the same organization as this employee.
    $orgCompanies = orgCompanyIdsForEmployee($db, $employeeId);
    if (!$orgCompanies) return; // can't resolve org — fail closed, don't guess
    $ph = implode(',', array_fill(0, count($orgCompanies), '?'));
    $managementEmpIds = $db->fetchAll(
        "SELECT e.id FROM employees e JOIN users u ON u.employee_id=e.id
          WHERE u.role IN ('hr','management','admin','superadmin') AND e.status='active' AND e.company_id IN ($ph)",
        $orgCompanies
    );
    foreach ($managementEmpIds as $row) {
        notify($db, (int)$row['id'], $title, $message, $type, $relatedId);
    }
}

// Every superadmin login *in the same organization as $employeeId* gets a
// copy of every notification created for that employee (apply, approve,
// reject), on top of whatever the reporting-head chain already sent.
// Scoped the same way as notifyReportingHead() above — a superadmin
// belonging to a different company/organization on the same install
// never sees this. Superadmins with a linked employee get it via
// employee_id (shows up next to their other notifications); superadmins
// with no linked employee get it via user_id instead.
function notifyAllSuperadmins(Database $db, int $employeeId, string $title, string $message, string $type, int $relatedId): void {
    $orgCompanies = orgCompanyIdsForEmployee($db, $employeeId);
    if (!$orgCompanies) return; // can't resolve org — fail closed, never leak cross-tenant
    $ph = implode(',', array_fill(0, count($orgCompanies), '?'));
    $rows = $db->fetchAll(
        "SELECT id, employee_id FROM users WHERE role='superadmin' AND is_active=1 AND company_id IN ($ph)",
        $orgCompanies
    );
    foreach ($rows as $u) {
        if (!empty($u['employee_id'])) {
            notify($db, (int)$u['employee_id'], $title, $message, $type, $relatedId);
        } else {
            notify($db, null, $title, $message, $type, $relatedId, (int)$u['id']);
        }
    }
}

// Replaces {{token}} placeholders in a custom letter template's body_html
// with the real values from $vars (see letter.php for the full list —
// employee_name, company_name, company_logo, etc). Any {{token}} that has
// no matching value is left blank rather than shown literally, so a typo
// in a template doesn't leak raw "{{...}}" text onto a printed letter.
function fillLetterTemplate(string $html, array $vars): string {
    return preg_replace_callback('/\{\{\s*([a-zA-Z0-9_]+)\s*\}\}/', function ($m) use ($vars) {
        return $vars[$m[1]] ?? '';
    }, $html);
}


// system (Lakh/Crore), since amounts throughout the app are shown in ₹.
// e.g. numToWords(45000) => "Forty Five Thousand"
//      numToWords(1250000) => "Twelve Lakh Fifty Thousand"
function numToWords(int $num): string {
    if ($num === 0) return 'Zero';
    if ($num < 0) return 'Minus ' . numToWords(-$num);

    $ones = ['', 'One', 'Two', 'Three', 'Four', 'Five', 'Six', 'Seven', 'Eight', 'Nine',
        'Ten', 'Eleven', 'Twelve', 'Thirteen', 'Fourteen', 'Fifteen', 'Sixteen',
        'Seventeen', 'Eighteen', 'Nineteen'];
    $tens = ['', '', 'Twenty', 'Thirty', 'Forty', 'Fifty', 'Sixty', 'Seventy', 'Eighty', 'Ninety'];

    $twoDigits = function (int $n) use ($ones, $tens): string {
        if ($n < 20) return $ones[$n];
        return trim($tens[intdiv($n, 10)] . ' ' . $ones[$n % 10]);
    };
    $threeDigits = function (int $n) use ($ones, $twoDigits): string {
        $out = '';
        if ($n >= 100) {
            $out .= $ones[intdiv($n, 100)] . ' Hundred ';
            $n %= 100;
        }
        $out .= $twoDigits($n);
        return trim($out);
    };

    $crore     = intdiv($num, 10000000); $num %= 10000000;
    $lakh      = intdiv($num, 100000);   $num %= 100000;
    $thousand  = intdiv($num, 1000);     $num %= 1000;
    $hundred   = $num;

    $parts = [];
    if ($crore)    $parts[] = $threeDigits($crore) . ' Crore';
    if ($lakh)     $parts[] = $threeDigits($lakh) . ' Lakh';
    if ($thousand) $parts[] = $threeDigits($thousand) . ' Thousand';
    if ($hundred)  $parts[] = $threeDigits($hundred);

    return trim(implode(' ', $parts));
}

// Converts a raw muster/attendance status code into the friendly label
// shown to users. On Duty is deliberately labelled "Present (On Duty)" —
// not just "On Duty" — since it counts as a present day everywhere else
// in the system (muster_summary.present_days, payroll, etc); the label
// should say so plainly rather than reading like a separate, non-present
// category.
function musterStatusLabel(?string $code): string {
    $map = [
        'P'   => 'Present',
        'A'   => 'Absent',
        'H'   => 'Half Day',
        'L'   => 'On Leave',
        'WO'  => 'Week Off',
        'PH'  => 'Holiday',
        'CO'  => 'Comp Off',
        'LOP' => 'Loss of Pay',
        'OD'  => 'Present (On Duty)',
    ];
    return $map[$code] ?? ($code ?: 'Not Marked');
}