<?php

error_reporting(E_ALL & ~E_DEPRECATED & ~E_STRICT);

ini_set('display_errors', 0);

ini_set('log_errors', 1);

error_reporting(E_ALL);

ini_set('error_log', 'php-error.log');

// ── Environment loader (.env) ──────────────────────────────────────────────

// Reads key=value pairs from .env and populates $_ENV / putenv().
  
// Uses parse_ini_file() — no external library required (works on shared hosting).

(static function (): void { 

    $envFile = __DIR__ . '/.env';

    if (!is_file($envFile)) {

        return;

    }

    $vars = parse_ini_file($envFile, false, INI_SCANNER_RAW);

    if (!is_array($vars)) {

        return;

    }

    foreach ($vars as $key => $value) {

        $key = (string) $key;

        $value = (string) $value;

        if (!array_key_exists($key, $_ENV)) {

            $_ENV[$key]  = $value;

            putenv("{$key}={$value}");

        }

    }

})();



// Database Configuration

define('DB_HOST', $_ENV['DB_HOST'] ?? 'localhost');

define('DB_USER', $_ENV['DB_USER'] ?? 'rvfcyvys_user');

define('DB_PASS', $_ENV['DB_PASS'] ?? '={%8AS~S]VWP');

define('DB_NAME', $_ENV['DB_NAME'] ?? 'rvfcyvys_xcpo');



// Site Configuration

define('SITE_NAME', 'EduTrack PRO');

define('BASE_URL', $_ENV['BASE_URL'] ?? 'https://edutrack.oricnetwork.com');

// Shared secret used to authenticate the Hubtel payment webhook callback.
// When set, the webhook rejects any request that does not present it.
define('HUBTEL_WEBHOOK_SECRET', $_ENV['HUBTEL_WEBHOOK_SECRET'] ?? '');



// Timezone

date_default_timezone_set('Africa/Accra');



// Error reporting — errors are logged, never displayed to users

error_reporting(E_ALL);

ini_set('display_errors', 0);

ini_set('log_errors', 1);



// Global database connection variable

$global_conn = null;



// Global configuration array

$config = [

    'arkesel' => [

        'api_key'    => $_ENV['ARKESEL_API_KEY'] ?? '',

        'sender_id'  => $_ENV['ARKESEL_SENDER_ID'] ?? '',

        'base_url'   => 'https://sms.arkesel.com/api/v2/sms/send',

    ],

    'paystack' => [

        'secret_key' => $_ENV['PAYSTACK_SECRET_KEY'] ?? '',

        'public_key' => $_ENV['PAYSTACK_PUBLIC_KEY'] ?? '',

        'base_url'   => 'https://api.paystack.co',

    ],

    'email' => [

        'smtp_host'     => $_ENV['SMTP_HOST'] ?? '',

        'smtp_user'     => $_ENV['SMTP_USER'] ?? '',

        'smtp_pass'     => $_ENV['SMTP_PASS'] ?? '',

        'smtp_port'     => (int) ($_ENV['SMTP_PORT'] ?? 587),

        'from_email'    => $_ENV['FROM_EMAIL'] ?? '',

        'from_name'     => $_ENV['FROM_NAME'] ?? '',

        'tech_email'    => $_ENV['TECH_EMAIL'] ?? '',

        'manager_email' => $_ENV['MANAGER_EMAIL'] ?? '',

        'ceo_email'     => $_ENV['CEO_EMAIL'] ?? '',

    ],

];



function getEmailConfigValue(string $key, $default = '')

{

    $stored = null;

    if (function_exists('getSettingValue')) {

        $stored = getSettingValue($key);

    }

    if ($stored !== null && $stored !== '') {

        return $stored;

    }



    global $config;

    return $config['email'][$key] ?? $default;

}



// Database Connection - IMPROVED VERSION

function getDBConnection() {

    global $global_conn;

    

    // Check if connection exists and is still alive.

    // mysqli::ping() was removed in PHP 8.4; use stat() which throws on a

    // closed connection (e.g. when page code called $conn->close() earlier).

    if ($global_conn !== null) {

        try {

            $global_conn->stat(); // throws Error if connection is closed

            return $global_conn;

        } catch (Throwable $e) {

            // Connection was closed externally — fall through to reconnect.

            $global_conn = null;

        }

    }

    

    $global_conn = new mysqli(DB_HOST, DB_USER, DB_PASS, DB_NAME);

    

    if ($global_conn->connect_error) {

        error_log("Database connection failed: " . $global_conn->connect_error);

        return null;

    }

    

    $global_conn->set_charset("utf8mb4");

    return $global_conn;

}



function closeDBConnection() {

    global $global_conn;

    if ($global_conn !== null) {

        try {

            $global_conn->close();

        } catch (Exception $e) {

            // Ignore errors

        }

        $global_conn = null;

    }

}



// Helper function to sanitize input

function sanitize($input) {

    if ($input === null) {

        return '';

    }

    

    $input = trim($input);

    $input = stripslashes($input);

    

    // Get connection for escaping

    $conn = getDBConnection();

    if ($conn) {

        // Don't double-escape - use real_escape_string before htmlspecialchars

        $input = $conn->real_escape_string($input);

    }

    

    return htmlspecialchars($input, ENT_QUOTES, 'UTF-8');

}



// Helper function to format currency - Ghana Cedi

function formatCurrency($amount) {

    if (!is_numeric($amount)) {

        $amount = 0;

    }

    return '₵' . number_format($amount, 2);

}



// Helper function to get current academic year — reads from settings first, falls back to date

function getAcademicYear() {

    // Try settings table first

    if (function_exists('getSettingValue')) {

        $stored = getSettingValue('academic_year');

        if (!empty($stored)) {

            return $stored;

        }

    }

    $year = date('Y');

    $month = date('m');

    return $month > 6 ? $year . '-' . ($year + 1) : ($year - 1) . '-' . $year;

}



// Helper function to get current term — reads from settings first, falls back to seasonal calculation

if (!function_exists('getCurrentTerm')) {

    function getCurrentTerm() {

        if (function_exists('getSettingValue')) {

            $stored = getSettingValue('current_term');

            if (!empty($stored)) {

                return $stored;

            }

        }

        $month = (int) date('n');

        if ($month >= 9 && $month <= 12) return 'First Term';

        if ($month >= 1 && $month <= 4) return 'Second Term';

        return 'Third Term';

    }

}



// ==================== ARKESEL SMS SERVICE ====================



/**

 * Send SMS using Arkesel API

 * 

 * @param string $phone Recipient phone number

 * @param string $message SMS message content

 * @return bool Success status

 */

function sendSMS($phone, $message) {

    global $config, $global_conn;



    $phone = formatPhoneNumber($phone);



    // Dry-run guard — used when testing against seeded data that carries real

    // phone numbers. Logs the message so success counters still exercise the

    // real code path, but never calls the SMS gateway. MUST be off in production.

    if (defined('COMMS_DRY_RUN') && COMMS_DRY_RUN) {

        error_log("[DRY RUN] SMS suppressed to {$phone}: " . substr($message, 0, 80));

        logSMS($phone, $message, 'DryRun');

        return true;

    }



    if (empty($config['arkesel']['api_key'])) {

        error_log("Arkesel API not configured");

        return false;

    }

    

    $data = [

        'sender' => $config['arkesel']['sender_id'],

        'message' => $message,

        'recipients' => [$phone]

    ];

    

    $headers = [

        'api-key: ' . $config['arkesel']['api_key'],

        'Content-Type: application/json'

    ];

    

    $ch = curl_init();

    curl_setopt($ch, CURLOPT_URL, $config['arkesel']['base_url']);

    curl_setopt($ch, CURLOPT_POST, true);

    curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);

    curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);

    curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);

    curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($data));

    

    $response = curl_exec($ch);

    $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);

    curl_close($ch);

    

    // Log SMS

    logSMS($phone, $message, $httpCode == 200 ? 'Success' : 'Failed');

    

    return $httpCode == 200;

}



/**

 * Format phone number to international format

 * 

 * @param string $phone Phone number to format

 * @return string Formatted phone number

 */

function formatPhoneNumber($phone) {

    // Remove any non-numeric characters

    $phone = preg_replace('/[^0-9]/', '', $phone);

    

    // If number starts with 0, convert to 233

    if (substr($phone, 0, 1) == '0') {

        $phone = '233' . substr($phone, 1);

    }

    

    // If number doesn't start with 233, add it

    if (substr($phone, 0, 3) != '233') {

        $phone = '233' . $phone;

    }

    

    return $phone;

}



/**

 * Log SMS to database

 * 

 * @param string $phone Recipient phone number

 * @param string $message SMS content

 * @param string $status Delivery status

 */

function logSMS($phone, $message, $status) {

    global $global_conn;

    

    try {

        if (!$global_conn) {

            $global_conn = getDBConnection();

        }

        

        $logQuery = "INSERT INTO sms_logs (phone, message, status, created_at) VALUES (?, ?, ?, NOW())";

        $stmt = $global_conn->prepare($logQuery);

        $stmt->bind_param("sss", $phone, $message, $status);

        $stmt->execute();

        $stmt->close();

    } catch (Exception $e) {

        error_log("Error logging SMS: " . $e->getMessage());

    }

}



// ==================== EMAIL SERVICE ====================



/**

 * Send email using PHPMailer

 * 

 * @param string $to Recipient email

 * @param string $subject Email subject

 * @param string $body Email body (HTML)

 * @param array $cc CC recipients

 * @param array $attachments File attachments

 * @return bool Success status

 */

function smtpReadResponse($socket) {

    $response = '';

    while (!feof($socket)) {

        $line = fgets($socket, 515);

        if ($line === false) {

            break;

        }

        $response .= $line;

        if (preg_match('/^\d{3}\s/', $line)) {

            break;

        }

    }

    return $response;

}



function smtpExpect($socket, string $command, array $expectedCodes): bool {

    if ($command !== '') {

        fwrite($socket, $command . "\r\n");

    }

    $response = smtpReadResponse($socket);

    $code = (int) substr(trim($response), 0, 3);

    if (!in_array($code, $expectedCodes, true)) {

        error_log("SMTP command failed: {$command} | Response: {$response}");

        return false;

    }

    return true;

}



function buildMimeMessage(array $config, string $to, string $subject, string $body, array $cc = [], array $attachments = []): string

{

    $fromEmail = getEmailConfigValue('from_email', $config['email']['from_email'] ?? '');

    $fromName = getEmailConfigValue('from_name', $config['email']['from_name'] ?? SITE_NAME);

    $headers = [

        'From: ' . sprintf('"%s" <%s>', addslashes($fromName), $fromEmail),

        'To: ' . $to,

        'Subject: ' . $subject,

        'MIME-Version: 1.0'

    ];



    if (!empty($cc)) {

        $headers[] = 'Cc: ' . implode(', ', $cc);

    }



    if (!empty($attachments)) {

        $boundary = 'mixed_' . bin2hex(random_bytes(12));

        $altBoundary = 'alt_' . bin2hex(random_bytes(12));

        $headers[] = 'Content-Type: multipart/mixed; boundary="' . $boundary . '"';



        $message = [];

        $message[] = '--' . $boundary;

        $message[] = 'Content-Type: multipart/alternative; boundary="' . $altBoundary . '"';

        $message[] = '';

        $message[] = '--' . $altBoundary;

        $message[] = 'Content-Type: text/plain; charset=UTF-8';

        $message[] = 'Content-Transfer-Encoding: 8bit';

        $message[] = '';

        $message[] = strip_tags($body);

        $message[] = '--' . $altBoundary;

        $message[] = 'Content-Type: text/html; charset=UTF-8';

        $message[] = 'Content-Transfer-Encoding: 8bit';

        $message[] = '';

        $message[] = $body;

        $message[] = '--' . $altBoundary . '--';



        foreach ($attachments as $attachment) {

            if (empty($attachment['path']) || !file_exists($attachment['path'])) {

                continue;

            }

            $filename = $attachment['name'] ?? basename($attachment['path']);

            $content = chunk_split(base64_encode((string) file_get_contents($attachment['path'])));

            $message[] = '--' . $boundary;

            $message[] = 'Content-Type: application/octet-stream; name="' . $filename . '"';

            $message[] = 'Content-Disposition: attachment; filename="' . $filename . '"';

            $message[] = 'Content-Transfer-Encoding: base64';

            $message[] = '';

            $message[] = $content;

        }



        $message[] = '--' . $boundary . '--';

        return implode("\r\n", array_merge($headers, [''], $message));

    }



    $headers[] = 'Content-Type: text/html; charset=UTF-8';

    $headers[] = 'Content-Transfer-Encoding: 8bit';



    return implode("\r\n", $headers) . "\r\n\r\nSubject: " . $subject . "\r\n\r\n" . $body;

}



function sendEmailViaSmtpFallback(string $to, string $subject, string $body, array $cc = [], array $attachments = []): bool

{

    global $config;



    $host = (string) getEmailConfigValue('smtp_host', $config['email']['smtp_host'] ?? '');

    $port = (int) getEmailConfigValue('smtp_port', $config['email']['smtp_port'] ?? 587);

    $username = (string) getEmailConfigValue('smtp_user', $config['email']['smtp_user'] ?? '');

    $password = (string) getEmailConfigValue('smtp_pass', $config['email']['smtp_pass'] ?? '');

    $fromEmail = (string) getEmailConfigValue('from_email', $config['email']['from_email'] ?? '');



    $socket = stream_socket_client("tcp://{$host}:{$port}", $errno, $errstr, 30);

    if (!$socket) {

        error_log("SMTP connection failed: {$errstr} ({$errno})");

        return false;

    }



    stream_set_timeout($socket, 30);



    if (!smtpExpect($socket, '', [220])) {

        fclose($socket);

        return false;

    }

    if (!smtpExpect($socket, 'EHLO localhost', [250])) {

        fclose($socket);

        return false;

    }

    if ($port === 587) {

        if (!smtpExpect($socket, 'STARTTLS', [220])) {

            fclose($socket);

            return false;

        }

        if (!stream_socket_enable_crypto($socket, true, STREAM_CRYPTO_METHOD_TLS_CLIENT)) {

            error_log('SMTP STARTTLS negotiation failed.');

            fclose($socket);

            return false;

        }

        if (!smtpExpect($socket, 'EHLO localhost', [250])) {

            fclose($socket);

            return false;

        }

    }

    if (!smtpExpect($socket, 'AUTH LOGIN', [334])) {

        fclose($socket);

        return false;

    }

    if (!smtpExpect($socket, base64_encode($username), [334])) {

        fclose($socket);

        return false;

    }

    if (!smtpExpect($socket, base64_encode($password), [235])) {

        fclose($socket);

        return false;

    }

    if (!smtpExpect($socket, 'MAIL FROM:<' . $fromEmail . '>', [250])) {

        fclose($socket);

        return false;

    }

    if (!smtpExpect($socket, 'RCPT TO:<' . $to . '>', [250, 251])) {

        fclose($socket);

        return false;

    }

    foreach ($cc as $ccEmail) {

        if (!smtpExpect($socket, 'RCPT TO:<' . $ccEmail . '>', [250, 251])) {

            fclose($socket);

            return false;

        }

    }

    if (!smtpExpect($socket, 'DATA', [354])) {

        fclose($socket);

        return false;

    }



    $mime = buildMimeMessage($config, $to, $subject, $body, $cc, $attachments);

    fwrite($socket, $mime . "\r\n.\r\n");

    $dataResponse = smtpReadResponse($socket);

    if ((int) substr(trim($dataResponse), 0, 3) !== 250) {

        error_log("SMTP DATA failed: {$dataResponse}");

        fclose($socket);

        return false;

    }



    smtpExpect($socket, 'QUIT', [221]);

    fclose($socket);

    return true;

}



function sendEmail($to, $subject, $body, $cc = [], $attachments = []) {

    global $config;



    // Dry-run guard — see sendSMS(). MUST be off in production.

    if (defined('COMMS_DRY_RUN') && COMMS_DRY_RUN) {

        error_log("[DRY RUN] Email suppressed to " . (is_array($to) ? implode(',', $to) : $to) . ": {$subject}");

        return true;

    }



    // Check if PHPMailer is available

    if (!class_exists('PHPMailer\PHPMailer\PHPMailer')) {

        $autoloadPath = __DIR__ . '/vendor/autoload.php';

        if (file_exists($autoloadPath)) {

            require_once $autoloadPath;

        }

    }



    if (!class_exists('PHPMailer\PHPMailer\PHPMailer')) {

        return sendEmailViaSmtpFallback($to, $subject, $body, $cc, $attachments);

    }

    

    try {

        $mail = new PHPMailer\PHPMailer\PHPMailer(true);

        

        // Server settings

        $mail->isSMTP();

        $mail->Host = getEmailConfigValue('smtp_host', $config['email']['smtp_host'] ?? '');

        $mail->SMTPAuth   = true;

        $mail->Username   = getEmailConfigValue('smtp_user', $config['email']['smtp_user'] ?? '');

        $mail->Password   = getEmailConfigValue('smtp_pass', $config['email']['smtp_pass'] ?? '');

        $mail->SMTPSecure = PHPMailer\PHPMailer\PHPMailer::ENCRYPTION_STARTTLS;

        $mail->Port = (int) getEmailConfigValue('smtp_port', $config['email']['smtp_port'] ?? 587);

        $mail->Timeout = 30;

        $mail->CharSet = 'UTF-8';



        // Recipients

        $mail->setFrom(

            getEmailConfigValue('from_email', $config['email']['from_email'] ?? ''),

            getEmailConfigValue('from_name', $config['email']['from_name'] ?? SITE_NAME)

        );

        $mail->addAddress($to);

        

        foreach ($cc as $email) {

            $mail->addCC($email);

        }

        

        // Attachments

        foreach ($attachments as $attachment) {

            if (file_exists($attachment['path'])) {

                $mail->addAttachment($attachment['path'], $attachment['name']);

            }

        }

        

        // Content

        $mail->isHTML(true);

        $mail->Subject = $subject;

        $mail->Body    = $body;

        $mail->AltBody = strip_tags($body);

        

        $mail->send();

        return true;

    } catch (Exception $e) {

        error_log("Email Error: " . $e->getMessage());

        return false;

    }

}



// ==================== PAYSTACK PAYMENT SERVICE ====================



/**

 * Initialize Paystack payment

 * 

 * @param array $data Payment data

 * @return array Response from Paystack

 */

function initializePaystackPayment($data) {

    global $config;

    $secretKey = getSettingValue('paystack_secret_key') ?: ($config['paystack']['secret_key'] ?? '');

    $baseUrl = $config['paystack']['base_url'] ?? 'https://api.paystack.co';

    if ($secretKey === '') {

        return [

            'success' => false,

            'data' => ['message' => 'Paystack secret key is not configured.'],

            'http_code' => 500

        ];

    }

    

    $url = $baseUrl . '/transaction/initialize';

    

    $headers = [

        'Authorization: Bearer ' . $secretKey,

        'Content-Type: application/json'

    ];



    // ── Auto-inject Paystack subaccount when one is configured ────────────

    // If paystack_split_enabled = '1' and a subaccount code exists, every

    // payment is automatically split: the full amount settles to the school's

    // linked bank account. bearer='account' means Paystack transaction fees

    // are charged to the subaccount (the school), not the main account.

    // When no subaccount is configured this block is skipped entirely and

    // the existing payment flow is unchanged.

    $subaccount_code = getSettingValue('paystack_subaccount_code');

    $split_enabled   = getSettingValue('paystack_split_enabled');

    if (!empty($subaccount_code) && $split_enabled === '1') {

        $data['subaccount']         = $subaccount_code;

        $data['bearer']             = getSettingValue('paystack_bearer') ?: 'account';

        $data['transaction_charge'] = 0;

    }

    // ─────────────────────────────────────────────────────────────────────



    $ch = curl_init();

    curl_setopt($ch, CURLOPT_URL, $url);

    curl_setopt($ch, CURLOPT_POST, true);

    curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);

    curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);

    curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);

    curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($data));



    $response = curl_exec($ch);

    $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);

    curl_close($ch);



    return [

        'success' => $httpCode == 200,

        'data' => json_decode($response, true),

        'http_code' => $httpCode

    ];

}



/**

 * Verify Paystack payment

 * 

 * @param string $reference Transaction reference

 * @return array Response from Paystack

 */

function verifyPaystackPayment($reference) {

    global $config;

    $secretKey = getSettingValue('paystack_secret_key') ?: ($config['paystack']['secret_key'] ?? '');

    $baseUrl = $config['paystack']['base_url'] ?? 'https://api.paystack.co';

    if ($secretKey === '') {

        return [

            'success' => false,

            'data' => ['message' => 'Paystack secret key is not configured.'],

            'http_code' => 500

        ];

    }

    

    $url = $baseUrl . '/transaction/verify/' . $reference;

    

    $headers = [

        'Authorization: Bearer ' . $secretKey,

        'Content-Type: application/json'

    ];

    

    $ch = curl_init();

    curl_setopt($ch, CURLOPT_URL, $url);

    curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);

    curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);

    curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);

    

    $response = curl_exec($ch);

    $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);

    curl_close($ch);

    

    return [

        'success' => $httpCode == 200,

        'data' => json_decode($response, true),

        'http_code' => $httpCode

    ];

}





// Add this function to your config.php file



/**

 * Log user activity for audit trail

 * 

 * @param mysqli $conn Database connection

 * @param int $user_id User ID performing the action

 * @param string $action Action type (e.g., 'Login', 'Add Student', 'Delete Payment')

 * @param string $description Detailed description of the action

 * @return bool Success status

 */

function logActivity($conn, $user_id, $action, $description) {

    // Get IP address

    $ip_address = $_SERVER['REMOTE_ADDR'] ?? '';

    

    // Get user agent

    $user_agent = $_SERVER['HTTP_USER_AGENT'] ?? '';

    

    // Prepare and execute insert

    $stmt = $conn->prepare("INSERT INTO user_activities (user_id, action, description, ip_address, user_agent, created_at) VALUES (?, ?, ?, ?, ?, NOW())");

    $stmt->bind_param("issss", $user_id, $action, $description, $ip_address, $user_agent);

    

    $result = $stmt->execute();

    $stmt->close();

    

    return $result;

}



/**

 * Get user activity log with optional filters

 * 

 * @param mysqli $conn Database connection

 * @param int $user_id Optional user ID filter

 * @param string $action Optional action filter

 * @param string $date_from Optional start date

 * @param string $date_to Optional end date

 * @param int $limit Number of records to return

 * @return array Array of activity records

 */

function getActivityLog($conn, $user_id = null, $action = null, $date_from = null, $date_to = null, $limit = 100) {

    $query = "SELECT ua.*, u.full_name, u.username, u.role 

              FROM user_activities ua 

              JOIN users u ON ua.user_id = u.id 

              WHERE 1=1";

    

    $params = [];

    $types = "";

    

    if ($user_id) {

        $query .= " AND ua.user_id = ?";

        $params[] = $user_id;

        $types .= "i";

    }

    

    if ($action) {

        $query .= " AND ua.action = ?";

        $params[] = $action;

        $types .= "s";

    }

    

    if ($date_from) {

        $query .= " AND DATE(ua.created_at) >= ?";

        $params[] = $date_from;

        $types .= "s";

    }

    

    if ($date_to) {

        $query .= " AND DATE(ua.created_at) <= ?";

        $params[] = $date_to;

        $types .= "s";

    }

    

    $query .= " ORDER BY ua.created_at DESC LIMIT ?";

    $params[] = $limit;

    $types .= "i";

    

    $stmt = $conn->prepare($query);

    

    if (!empty($params)) {

        $stmt->bind_param($types, ...$params);

    }

    

    $stmt->execute();

    $result = $stmt->get_result();

    

    $activities = [];

    while ($row = $result->fetch_assoc()) {

        $activities[] = $row;

    }

    

    $stmt->close();

    return $activities;

}



// Session start - moved from global scope

if (session_status() === PHP_SESSION_NONE) {

    session_start();

}



// Load PHPMailer if needed (add this to your main pages)

// require_once 'path/to/PHPMailer/src/PHPMailer.php';

// require_once 'path/to/PHPMailer/src/SMTP.php';

// require_once 'path/to/PHPMailer/src/Exception.php';



if (!function_exists('requireAuth')) {

    /**

     * Call at the top of any protected page.

     * Usage: requireAuth(['admin', 'headteacher']);

     * Omit $roles to allow any logged-in user.

     */

    function requireAuth(array $roles = []) {

        if (!isset($_SESSION['user_id'])) {

            header('Location: ' . BASE_URL . '/login.php');

            exit;

        }

        if (!empty($roles) && !in_array($_SESSION['role'] ?? '', $roles, true)) {

            header('Location: ' . BASE_URL . '/dashboard.php?error=access_denied');

            exit;

        }

    }

}



// NOTE: a second, 2-argument logActivity(string $action, string $details = '')
// used to be declared here behind a function_exists() guard. Because the
// 4-argument logActivity($conn, $user_id, $action, $description) above is
// declared unconditionally and earlier, that guard never fired and the 2-arg
// form was dead code -- but it misled callers into using the 2-arg signature,
// which fataled at runtime with ArgumentCountError. Removed; use the 4-arg form.

if (!function_exists('getSettingValue')) {

    function getSettingValue($key) {

        // Always route through getDBConnection() so the stat()-based reconnect

        // logic handles any closed connection rather than dying on prepare().

        $conn = getDBConnection();

        if (!$conn) {

            return null;

        }

        $stmt = $conn->prepare(

            "SELECT setting_value FROM settings WHERE setting_key = ? LIMIT 1"

        );

        if (!$stmt) {

            return null;

        }

        $stmt->bind_param('s', $key);

        $stmt->execute();

        $result = $stmt->get_result();

        $row = $result->fetch_assoc();

        $stmt->close();

        return $row ? $row['setting_value'] : null;

    }

}



if (!function_exists('hubtelRequestPayment')) {

    function hubtelRequestPayment($amount, $phone, $reference, $student_id, $payment_id) {

        $client_id = getSettingValue('hubtel_client_id');

        $client_secret = getSettingValue('hubtel_client_secret');

        $sender_id = getSettingValue('hubtel_sender_id') ?: 'EduTrack';



        $cleanPhone = preg_replace('/\s+/', '', (string) $phone);

        $cleanPhone = preg_replace('/^\+?233/', '', $cleanPhone);

        $cleanPhone = ltrim($cleanPhone, '0');

        $phone = '233' . $cleanPhone;



        $network = 'mtn';

        if (preg_match('/^(233)?(20|50|53|54|55|59)/', $phone)) {

            $network = 'mtn';

        } elseif (preg_match('/^(233)?(24|25|54)/', $phone)) {

            $network = 'vodafone';

        } elseif (preg_match('/^(233)?(26|27|56|57)/', $phone)) {

            $network = 'airteltigo';

        }



        $payload = [

            'Amount' => $amount,

            'Title' => 'School Fee Payment',

            'Description' => 'EduTrack fee payment',

            'ClientReference' => $reference,

            'CallbackUrl' => rtrim(BASE_URL, '/') . '/payment_webhook_hubtel.php',

            'ReturnUrl' => rtrim(BASE_URL, '/') . '/payment_receipt.php?ref=' . $reference,

            'CancellationUrl' => rtrim(BASE_URL, '/') . '/payment_checkout.php',

            'Logo' => rtrim(BASE_URL, '/') . '/uploads/school_logo.png',

            'Actions' => [

                'Charge' => [

                    'Channel' => 'momo-gh',

                    'Network' => $network,

                    'PhoneNumber' => $phone,

                    'Recurrence' => 'OneTime',

                    'CustomerName' => 'Parent/Guardian',

                    'CustomerPhone' => $phone,

                    'ApiKey' => $client_secret,

                    'SenderId' => $sender_id

                ]

            ],

            'Metadata' => [

                'student_id' => $student_id,

                'payment_id' => $payment_id

            ]

        ];



        $ch = curl_init('https://payproxyapi.hubtel.com/items/initiate');

        curl_setopt_array($ch, [

            CURLOPT_RETURNTRANSFER => true,

            CURLOPT_POST => true,

            CURLOPT_POSTFIELDS => json_encode($payload),

            CURLOPT_HTTPHEADER => [

                'Content-Type: application/json',

                'Authorization: Basic ' . base64_encode($client_id . ':' . $client_secret)

            ]

        ]);

        $response = curl_exec($ch);

        if ($response === false) {

            error_log('Hubtel request failed: ' . curl_error($ch));

        }

        curl_close($ch);

        return json_decode($response ?: '{}', true);

    }

}



// ── Canteen Module ────────────────────────────────────────────────

// CANTEEN_SECRET_KEY: used for HMAC token signing. NEVER store in DB.

// Generate once per install: bin2hex(random_bytes(32))

if (!defined('CANTEEN_SECRET_KEY')) {

    define('CANTEEN_SECRET_KEY', $_ENV['CANTEEN_SECRET_KEY'] ?? 'CHANGE_ME_generate_with_bin2hex_random_bytes_32');

}

if (!defined('CANTEEN_ADDON_KEY')) {

    define('CANTEEN_ADDON_KEY', 'canteen_module');

}

if (!defined('CANTEEN_CRON_TOKEN')) {

    define('CANTEEN_CRON_TOKEN', $_ENV['CANTEEN_CRON_TOKEN'] ?? 'CHANGE_ME_generate_random_token');

}

if (!defined('TIMETABLE_CRON_TOKEN')) {

    define('TIMETABLE_CRON_TOKEN', $_ENV['TIMETABLE_CRON_TOKEN'] ?? 'tt-notify-cron-change-me');

}



// ── Outbound comms dry run ───────────────────────────────────────

// When true, sendSMS() and sendEmail() log and return success without calling

// the gateway. For testing against seeded data that carries real contacts.

// Defaults to OFF — must be switched on explicitly via COMMS_DRY_RUN in .env.

// LEAVE THIS UNSET (or false) IN PRODUCTION.

if (!defined('COMMS_DRY_RUN')) {

    define('COMMS_DRY_RUN', filter_var($_ENV['COMMS_DRY_RUN'] ?? 'false', FILTER_VALIDATE_BOOLEAN));

}



// ── AI diagnostics ───────────────────────────────────────────────

// When true, the RBC AI dashboard exposes a live provider health panel that

// actually calls each provider. Diagnostics only — it costs a request per

// provider per page load. Defaults to OFF.

// LEAVE THIS UNSET (or false) IN PRODUCTION.

if (!defined('AI_DEBUG')) {

    define('AI_DEBUG', filter_var($_ENV['AI_DEBUG'] ?? 'false', FILTER_VALIDATE_BOOLEAN));

}



// ── AI Engine Configuration ──────────────────────────────────────

// ENGINE: Claude (cheap quality fallback — Haiku). Cascade order is set in
// provider_engine.php; the cheap engines (Gemini/Groq) run first, Claude Haiku
// is the mid fallback, and Claude Sonnet is the final quality safety net.

if (!defined('CLAUDE_API_KEY')) {

    define('CLAUDE_API_KEY', $_ENV['CLAUDE_API_KEY'] ?? '');

}

if (!defined('CLAUDE_MODEL')) {

    // Haiku 4.5 — ~5x cheaper than Opus 4.5, fully capable of structured
    // question + diagram-spec JSON. Sonnet is used only for the last fallback.
    define('CLAUDE_MODEL', 'claude-haiku-4-5');

}



// ENGINE 2: Groq (First Fallback — free tier, very fast)

// Get your key at: https://console.groq.com

if (!defined('GROQ_API_KEY')) {

    define('GROQ_API_KEY', $_ENV['GROQ_API_KEY'] ?? '');

}

if (!defined('GROQ_MODEL')) {

    define('GROQ_MODEL', 'llama-3.3-70b-versatile');

}



// ENGINE 3: Gemini Flash (Last Fallback — free tier, reliable)

// Get your key at: https://aistudio.google.com

if (!defined('GEMINI_API_KEY')) {

    define('GEMINI_API_KEY', $_ENV['GEMINI_API_KEY'] ?? '');

}

if (!defined('GEMINI_MODEL')) {

    define('GEMINI_MODEL', 'gemini-2.0-flash');

}



// Active engine tracking (set dynamically at runtime — do not edit)

// Paths

if (!defined('EXAM_UPLOAD_DIR')) {

    define('EXAM_UPLOAD_DIR', __DIR__ . '/uploads/exam_materials/');

}

if (!defined('EXAM_EXPORT_DIR')) {

    define('EXAM_EXPORT_DIR', __DIR__ . '/exams_generator/exports/');

}



// ── License & Module Access Control ──────────────────────────────

if (!defined('LICENSE_CACHE')) {

    define('LICENSE_CACHE', __DIR__ . '/license.json');

}

if (!defined('LICENSE_SERVER_URL')) {

    // Read from .env first; fall back to same-host path only for local dev.

    define('LICENSE_SERVER_URL', $_ENV['LICENSE_SERVER_URL']

        ?? rtrim(BASE_URL, '/') . '/license-server/api.php');

}

if (!defined('SCHOOL_CODE')) {

    define('SCHOOL_CODE', $_ENV['SCHOOL_CODE'] ?? 'DEMO');

}



/**

 * Load and cache the school license.

 * Returns array with keys: plan, student_limit, modules[], addons[], source, cached_at

 */

function checkLicense(): array

{

    static $_license_cache = null;

    if ($_license_cache !== null) {

        return $_license_cache;

    }



    $cache_file = LICENSE_CACHE;

    $cache = null;



    if (file_exists($cache_file)) {

        $raw = @file_get_contents($cache_file);

        if ($raw) {

            $cache = json_decode($raw, true);

        }

    }



    // Valid if cached < 24 hours ago

    $needs_refresh = true;

    if ($cache && !empty($cache['cached_at'])) {

        $cached_time = strtotime($cache['cached_at']);

        if ($cached_time && (time() - $cached_time) < 86400) {

            $needs_refresh = false;

        }

    }



    if ($needs_refresh) {

        // Try license server (5s timeout; never blocks the page hard)

        $fresh = null;

        if (function_exists('curl_init')) {

            $ch = curl_init();

            curl_setopt_array($ch, [

                CURLOPT_URL            => LICENSE_SERVER_URL . '?school_code=' . urlencode(SCHOOL_CODE),

                CURLOPT_RETURNTRANSFER => true,

                CURLOPT_TIMEOUT        => 5,

                CURLOPT_SSL_VERIFYPEER => true,

            ]);

            $response = curl_exec($ch);

            curl_close($ch);

            if ($response) {

                $decoded = json_decode($response, true);

                if ($decoded && !empty($decoded['plan'])) {

                    $fresh = $decoded;

                }

            }

        }



        if ($fresh) {

            $fresh['cached_at'] = date('c');

            $fresh['source']    = 'server';

            @file_put_contents($cache_file, json_encode($fresh, JSON_PRETTY_PRINT));

            $_license_cache = $fresh;

            return $fresh;

        }



        // Offline grace: use stale cache or built-in default

        if ($cache) {

            $_license_cache = $cache;

            return $cache;

        }



        $default = _defaultLicense();

        @file_put_contents($cache_file, json_encode($default, JSON_PRETTY_PRINT));

        $_license_cache = $default;

        return $default;

    }



    $_license_cache = $cache;

    return $cache;

}



/** Fallback: full premium access (used when no cache exists and server unreachable) */

function _defaultLicense(): array

{

    if (!defined('EDUTRACK_MODULES')) {

        require_once __DIR__ . '/module_definitions.php';

    }

    return [

        'school_code'   => SCHOOL_CODE,

        'plan'          => 'premium',

        'student_limit' => 0,

        'modules'       => array_keys(EDUTRACK_MODULES),

        'addons'        => [],

        'valid_until'   => '2099-12-31',

        'cached_at'     => date('c'),

        'source'        => 'default',

    ];

}



/** Check whether a module key is active for this school's license */

function hasModule(string $key): bool

{

    $license = checkLicense();

    return in_array($key, $license['modules'] ?? [], true);

}



/**

 * Gate a page or AJAX endpoint by module.

 * If locked: AJAX → 403 JSON; page → redirect to upgrade.php

 */

function requireModule(string $key, bool $is_ajax = false): void

{

    // ── Role-based access check (before license check) ──

    if (!defined('EDUTRACK_MODULES')) {

        require_once __DIR__ . '/module_definitions.php';

    }

    $role        = $_SESSION['role'] ?? 'tutor';

    $role_access = defined('ROLE_ACCESS') ? (ROLE_ACCESS[$role] ?? []) : 'all';



    if ($role_access !== 'all' && !in_array($key, (array) $role_access, true)) {

        if ($is_ajax) {

            header('Content-Type: application/json', true, 403);

            echo json_encode(['success' => false, 'message' => 'You do not have permission to access this feature.']);

            exit;

        }

        $dashboard = (defined('BASE_URL') ? rtrim(BASE_URL, '/') : '') . '/dashboard.php?error=no_access';

        header('Location: ' . $dashboard);

        exit;

    }



    // ── License check ──

    if (!hasModule($key)) {

        $upgrade = (defined('BASE_URL') ? rtrim(BASE_URL, '/') : '') . '/upgrade.php?module=' . rawurlencode($key);

        if ($is_ajax) {

            header('Content-Type: application/json', true, 403);

            echo json_encode([

                'success'     => false,

                'error'       => 'module_locked',

                'upgrade_url' => $upgrade,

            ]);

            exit;

        }

        header('Location: ' . $upgrade);

        exit;

    }

}



/**

 * Abort adding a student if the plan's student slot limit is reached.

 * Call only before INSERT, not on list pages.

 */

function requireStudentSlot(bool $is_ajax = false): void

{

    $license = checkLicense();

    $limit   = (int) ($license['student_limit'] ?? 0);

    if ($limit <= 0) {

        return; // 0 = unlimited

    }

    $conn   = getDBConnection();

    $result = $conn->query("SELECT COUNT(*) AS cnt FROM students WHERE status='active'");

    $count  = $result ? (int) $result->fetch_assoc()['cnt'] : 0;

    if ($count >= $limit) {

        $upgrade = (defined('BASE_URL') ? rtrim(BASE_URL, '/') : '') .

                   '/upgrade.php?module=students&reason=limit&limit=' . $limit;

        if ($is_ajax) {

            header('Content-Type: application/json', true, 403);

            echo json_encode([

                'success'     => false,

                'error'       => 'student_limit_reached',

                'limit'       => $limit,

                'count'       => $count,

                'upgrade_url' => $upgrade,

            ]);

            exit;

        }

        header('Location: ' . $upgrade);

        exit;

    }

}



/** Return the list of active module keys for this school */

function getActiveModules(): array

{

    return checkLicense()['modules'] ?? [];

}



/** Return the student limit (0 = unlimited) */

function getStudentLimit(): int

{

    return (int) (checkLicense()['student_limit'] ?? 0);

}



/** URL to the upgrade page, optionally anchored to a locked module */

function getUpgradeUrl(string $module = ''): string

{

    $base = defined('BASE_URL') ? rtrim(BASE_URL, '/') : '';

    return $base . '/upgrade.php' . ($module ? '?module=' . rawurlencode($module) : '');

}



/**

 * Enforce the license state. Call once near the top of any protected page.

 * Sets:  LICENSE_SUSPENDED (bool constant), LICENSE_STATUS (string constant)

 * When suspended: outputs a fixed red banner and blocks POST/AJAX.

 */

if (!function_exists('enforceLicense')) {

function enforceLicense(bool $is_ajax = false): void

{

    $license  = checkLicense();

    $status   = $license['status'] ?? 'active';

    $active   = (bool)($license['is_active'] ?? true);



    if (!empty($license['valid_until']) && $license['valid_until'] < date('Y-m-d')) {

        $status = 'suspended'; $active = false;

    }

    $suspended = (!$active || in_array($status, ['suspended', 'cancelled'], true));

    if (!defined('LICENSE_SUSPENDED')) define('LICENSE_SUSPENDED', $suspended);

    if (!defined('LICENSE_STATUS'))    define('LICENSE_STATUS',    $status);

    if (!$suspended) return;



    $is_xhr = isset($_SERVER['HTTP_X_REQUESTED_WITH']) &&

              strtolower($_SERVER['HTTP_X_REQUESTED_WITH']) === 'xmlhttprequest';

    if ($is_ajax || $is_xhr || $_SERVER['REQUEST_METHOD'] === 'POST') {

        if (!headers_sent()) header('Content-Type: application/json', true, 403);

        echo json_encode(['success'=>false,'error'=>'license_suspended',

            'message'=>'Your license is suspended. Contact Oric Network.']);

        exit;

    }

    $upgrade = (defined('BASE_URL') ? rtrim(BASE_URL,'/') : '') . '/upgrade.php';

    echo '<div id="lic-banner" style="position:fixed;top:0;left:0;right:0;z-index:99999;'

        .'background:linear-gradient(90deg,#dc3545,#b02a37);color:#fff;padding:.75rem 1.5rem;'

        .'display:flex;align-items:center;justify-content:space-between;font-family:\'Segoe UI\','

        .'sans-serif;font-size:.9rem;box-shadow:0 4px 20px rgba(220,53,69,.4);">'

        .'<div><strong>&#9888; License Suspended</strong> &mdash; Your EduTrack license is '

        .'suspended. Some features are disabled.</div>'

        .'<a href="'.$upgrade.'" style="color:#fff;text-decoration:underline;margin-left:1rem">'

        .'Reactivate &rarr;</a></div><div style="height:52px"></div>';

}

}



/**

 * Build the full sidebar HTML.

 * Pass basename($_SERVER['PHP_SELF']) as $current_page.

 */

/**

 * Returns the unread internal-message count for the current logged-in user.

 * Used as the badge_callback for the Communication sidebar item.

 */

function getCommUnreadCount(): int

{

    try {

        if (!isset($_SESSION['user_id'])) return 0;

        $conn = getDBConnection();

        if (!$conn instanceof mysqli) return 0;

        $uid = (int) $_SESSION['user_id'];

        // Check table exists first to avoid errors on fresh installs

        $tableCheck = $conn->query("SHOW TABLES LIKE 'internal_messages'");

        if (!$tableCheck || $tableCheck->num_rows === 0) return 0;

        $r = $conn->query("SELECT COUNT(*) AS cnt FROM internal_messages WHERE recipient_id = {$uid} AND is_read = 0");

        return $r ? (int) $r->fetch_assoc()['cnt'] : 0;

    } catch (Throwable $e) {

        error_log('getCommUnreadCount error: ' . $e->getMessage());

        return 0;

    }

}



function buildSidebar(string $current_page = ''): string

{

    try {

    if (!defined('EDUTRACK_MODULES')) {

        require_once __DIR__ . '/module_definitions.php';

    }

    $active_modules = getActiveModules();

    $base           = defined('BASE_URL') ? rtrim(BASE_URL, '/') . '/' : '/';



    $html  = '<div class="sidebar-header">';

    $html .= '<h3><i class="fas fa-graduation-cap me-2"></i>EduTrack</h3></div>';

    $html .= '<ul class="nav flex-column">';



    $current_section = 'core';



    // Role-based nav filtering

    $nav_role        = $_SESSION['role'] ?? 'tutor';

    $nav_role_access = defined('ROLE_ACCESS') ? (ROLE_ACCESS[$nav_role] ?? []) : 'all';



    foreach (EDUTRACK_MODULES as $key => $def) {

        // Skip modules this role cannot access

        if ($nav_role_access !== 'all' && !in_array($key, (array) $nav_role_access, true)) {

            continue;

        }

        // Skip add-ons that configure themselves inside another page (e.g. Settings)

        if (!empty($def['hidden_nav'])) {

            continue;

        }

        // Determine display section

        if (!$def['core']) {

            $plans = $def['plans'];

            if (count($plans) === 1 && $plans[0] === 'premium') {

                $module_section = 'premium';

            } else {

                $module_section = 'growth';

            }

        } else {

            $module_section = 'core';

        }



        // Insert section divider when section changes

        if ($module_section !== $current_section) {

            $current_section = $module_section;

            $html .= '<li class="nav-item"><hr class="my-1 sidebar-divider"></li>';

            $label = ($module_section === 'premium') ? 'AI &amp; PREMIUM' : 'MODULES';

            $html .= '<li class="nav-item"><span class="sidebar-section-label">' . $label . '</span></li>';

        }



        $is_unlocked  = in_array($key, $active_modules, true);

        $page_base    = basename($def['path']);

        $is_current   = ($current_page === $page_base || $current_page === $def['path']);

        $active_class = $is_current ? ' active' : '';



        if ($key === 'canteen_module') {

            // Canteen has a collapse sub-menu; Express Collection is highlighted for cashiers

            if ($is_unlocked) {

                $canteen_pages   = ['fees_express.php','canteen/setup.php','canteen/scanner.php',

                                    'canteen/cook_dashboard.php','canteen/reports.php','canteen/settings.php',

                                    'canteen/hardware_test.php','canteen/enroll_fingerprint.php','canteen/enroll_facial.php',

                                    'admin/print_all_id_cards.php'];

                $on_canteen_page = in_array($current_page, $canteen_pages, true)

                                   || strpos($current_page, 'canteen/') === 0;

                $collapse_open   = $on_canteen_page ? 'show' : '';

                $html .= '<li class="nav-item">';

                $html .= '<a class="nav-link" data-bs-toggle="collapse" href="#sidebarCanteenMenu" role="button">';

                $html .= '<i class="fas fa-utensils me-2"></i>Canteen';

                $html .= '<i class="fas fa-chevron-down ms-auto"></i></a>';

                $html .= '<div class="collapse ' . $collapse_open . '" id="sidebarCanteenMenu">';

                $html .= '<ul class="nav flex-column ms-3">';

                // Express Collection — primary, highlighted for cashiers

                $express_active = ($current_page === 'fees_express.php') ? ' active fw-semibold' : '';

                $html .= '<li><a class="nav-link' . $express_active . '" href="' . $base . 'fees_express.php">'

                      .  '<i class="fas fa-bolt text-warning me-1"></i>Express Collection</a></li>';

                // Canteen Scanner

                $scan_active = ($current_page === 'canteen/scanner.php') ? ' active' : '';

                $html .= '<li><a class="nav-link' . $scan_active . '" href="' . $base . 'canteen/scanner.php">'

                      .  '<i class="fas fa-qrcode me-1"></i>Canteen Scanner</a></li>';

                // Role-gated items

                $canteen_role = $_SESSION['role'] ?? '';

                if (in_array($canteen_role, ['admin','headteacher'], true)) {

                    $setup_active = ($current_page === 'canteen/setup.php') ? ' active' : '';

                    $html .= '<li><a class="nav-link' . $setup_active . '" href="' . $base . 'canteen/setup.php">'

                          .  '<i class="fas fa-calendar-plus me-1"></i>Session Setup</a></li>';

                }

                $dash_active = ($current_page === 'canteen/cook_dashboard.php') ? ' active' : '';

                $html .= '<li><a class="nav-link' . $dash_active . '" href="' . $base . 'canteen/cook_dashboard.php">'

                      .  '<i class="fas fa-chart-bar me-1"></i>Cook Dashboard</a></li>';

                if (in_array($canteen_role, ['admin','headteacher','accountant'], true)) {

                    $rep_active = ($current_page === 'canteen/reports.php') ? ' active' : '';

                    $html .= '<li><a class="nav-link' . $rep_active . '" href="' . $base . 'canteen/reports.php">'

                          .  '<i class="fas fa-file-alt me-1"></i>Reports</a></li>';

                }

                if (in_array($canteen_role, ['admin','headteacher'], true)) {

                    $idc_active = ($current_page === 'admin/print_all_id_cards.php') ? ' active' : '';

                    $html .= '<li><a class="nav-link' . $idc_active . '" href="' . $base . 'admin/print_all_id_cards.php">'

                          .  '<i class="fas fa-id-card me-1"></i>Student ID Cards</a></li>';

                    // Biometric enrollment links (only when respective feature enabled)

                    $canteen_biometric_settings = function_exists('canteen_get_settings') ? canteen_get_settings() : [];

                    if (!empty($canteen_biometric_settings['fingerprint_enabled'])) {

                        $fp_active = ($current_page === 'canteen/enroll_fingerprint.php') ? ' active' : '';

                        $html .= '<li><a class="nav-link' . $fp_active . '" href="' . $base . 'canteen/enroll_fingerprint.php">'

                              .  '<i class="fas fa-fingerprint me-1"></i>Enroll Fingerprints</a></li>';

                    }

                    if (!empty($canteen_biometric_settings['facial_recognition_enabled'])) {

                        $fc_active = ($current_page === 'canteen/enroll_facial.php') ? ' active' : '';

                        $html .= '<li><a class="nav-link' . $fc_active . '" href="' . $base . 'canteen/enroll_facial.php">'

                              .  '<i class="fas fa-user-circle me-1"></i>Enroll Facial</a></li>';

                    }

                    $hw_active  = ($current_page === 'canteen/hardware_test.php') ? ' active' : '';

                    $html .= '<li><a class="nav-link' . $hw_active . '" href="' . $base . 'canteen/hardware_test.php">'

                          .  '<i class="fas fa-microchip me-1"></i>Hardware Test</a></li>';

                    $set_active = ($current_page === 'canteen/settings.php') ? ' active' : '';

                    $html .= '<li><a class="nav-link' . $set_active . '" href="' . $base . 'canteen/settings.php">'

                          .  '<i class="fas fa-cog me-1"></i>Settings</a></li>';

                }

                $html .= '</ul></div></li>';

            } else {

                $html .= '<li class="nav-item">';

                $lock_href = (($nav_role ?? '') === 'admin')

                    ? $base . 'admin/addons.php'

                    : $base . 'canteen/upgrade.php';

                $html .= '<a href="' . $lock_href . '" class="nav-link nav-link-locked">';

                $html .= '<i class="fas fa-utensils me-2"></i>Canteen';

                $html .= '<i class="fas fa-lock ms-auto small"></i></a></li>';

            }

        } elseif ($key === 'exams') {

            // Exams has a Bootstrap collapse sub-menu

            if ($is_unlocked) {

                $collapse_open = (strpos($current_page, 'exams_generator') !== false) ? 'show' : '';

                $html .= '<li class="nav-item">';

                $html .= '<a class="nav-link" data-bs-toggle="collapse" href="#sidebarExamsMenu" role="button">';

                $html .= '<i class="fas ' . $def['icon'] . ' me-2"></i>' . htmlspecialchars($def['label']);

                $html .= '<i class="fas fa-chevron-down ms-auto"></i></a>';

                $html .= '<div class="collapse ' . $collapse_open . '" id="sidebarExamsMenu">';

                $html .= '<ul class="nav flex-column ms-3">';

                $html .= '<li><a class="nav-link" href="' . $base . 'exams_generator/index.php">Question Bank</a></li>';

                $html .= '<li><a class="nav-link" href="' . $base . 'exams_generator/ai_generator.php">AI Generator</a></li>';

                $html .= '<li><a class="nav-link" href="' . $base . 'exams_generator/paper_builder.php">Paper Builder</a></li>';

                $html .= '<li><a class="nav-link" href="' . $base . 'exams_generator/schedules.php">Schedules</a></li>';

                $html .= '<li><a class="nav-link" href="' . $base . 'exams_generator/results.php">Results</a></li>';

                $html .= '</ul></div></li>';

            } else {

                $html .= '<li class="nav-item">';

                $html .= '<a href="' . $base . 'upgrade.php?module=' . $key . '" class="nav-link nav-link-locked">';

                $html .= '<i class="fas ' . $def['icon'] . ' me-2"></i>' . htmlspecialchars($def['label']);

                $html .= '<i class="fas fa-lock ms-auto small"></i></a></li>';

            }

        } elseif ($is_unlocked) {

            // Check for badge callback (e.g. unread count)

            $badge_html = '';

            if (!empty($def['badge_callback']) && function_exists($def['badge_callback'])) {

                $badge_count = (int) call_user_func($def['badge_callback']);

                if ($badge_count > 0) {

                    $badge_html = ' <span class="badge bg-danger rounded-pill ms-auto" style="font-size:.65rem">'

                        . min($badge_count, 99)

                        . '</span>';

                }

            }

            $html .= '<li class="nav-item">';

            $html .= '<a href="' . $base . $def['path'] . '" class="nav-link' . $active_class . '">';

            $html .= '<i class="fas ' . $def['icon'] . ' me-2"></i>' . htmlspecialchars($def['label']);

            $html .= $badge_html;

            $html .= '</a></li>';

        } else {

            $html .= '<li class="nav-item">';

            $html .= '<a href="' . $base . 'upgrade.php?module=' . $key . '" class="nav-link nav-link-locked">';

            $html .= '<i class="fas ' . $def['icon'] . ' me-2"></i>' . htmlspecialchars($def['label']);

            $html .= '<i class="fas fa-lock ms-auto small"></i></a></li>';

        }

    }



    // ── Teacher Portal quick-access (tutors and admins) ─────────────────────

    $tp_role = $_SESSION['role'] ?? '';

    if (in_array($tp_role, ['tutor', 'admin', 'headteacher'], true)) {

        $tp_active = (strpos($current_page, 'teacher-portal') !== false) ? ' active' : '';

        $html .= '<li class="nav-item">';

        $html .= '<a href="' . $base . 'teacher-portal/index.php" class="nav-link' . $tp_active . '" style="color:#a5b4fc;">';

        $html .= '<i class="fas fa-chalkboard-teacher me-2"></i>Teacher Portal';

        $html .= '</a></li>';

    }



    $html .= '<hr>';

    $html .= '<li class="nav-item"><a href="' . $base . 'logout.php" class="nav-link">';

    $html .= '<i class="fas fa-sign-out-alt me-2"></i>Logout</a></li>';

    $html .= '</ul>';



    // ── PWA: install button (shown by JS when browser fires beforeinstallprompt) ──

    $html .= '<div id="pwaInstallWrap" style="display:none;padding:.5rem 1rem .75rem;">'

           . '<button id="pwaInstallBtn" type="button" '

           . 'style="width:100%;background:rgba(255,255,255,.12);color:#fff;border:1px solid rgba(255,255,255,.3);'

           . 'border-radius:.5rem;padding:.45rem .75rem;font-size:.82rem;cursor:pointer;display:flex;align-items:center;gap:.5rem;">'

           . '<i class="fas fa-download"></i>Install App</button></div>';



    // ── PWA bootstrap script ───────────────────────────────────────────────────

    $swUrl       = rtrim(BASE_URL, '/') . '/sw.php';

    $manifestUrl = rtrim(BASE_URL, '/') . '/manifest.php';

    $swScope     = rtrim((string) parse_url(rtrim(BASE_URL, '/'), PHP_URL_PATH), '/') . '/';



    $html .= <<<PWASCRIPT

<script>

(function(){

'use strict';



// ── 1. Inject <link rel="manifest"> into <head> if not already present ─────

if (!document.querySelector('link[rel="manifest"]')) {

  var ml = document.createElement('link');

  ml.rel = 'manifest'; ml.href = '{$manifestUrl}';

  document.head.appendChild(ml);

}

// ── 2. Inject theme-color meta ─────────────────────────────────────────────

if (!document.querySelector('meta[name="theme-color"]')) {

  var tm = document.createElement('meta');

  tm.name = 'theme-color'; tm.content = '#2563eb';

  document.head.appendChild(tm);

}

// ── 3. PWA meta tags (standard + Apple) ───────────────────────────────────

[['mobile-web-app-capable','yes'],

 ['apple-mobile-web-app-capable','yes'],

 ['apple-mobile-web-app-status-bar-style','black-translucent'],

 ['apple-mobile-web-app-title','EduTrack']

].forEach(function(pair){

  if (!document.querySelector('meta[name="'+pair[0]+'"]')) {

    var m = document.createElement('meta');

    m.name = pair[0]; m.content = pair[1];

    document.head.appendChild(m);

  }

});



// ── 4. Register service worker ─────────────────────────────────────────────

if ('serviceWorker' in navigator) {

  window.addEventListener('load', function(){

    navigator.serviceWorker.register('{$swUrl}', {scope: '{$swScope}'})

      .then(function(r){ console.log('[EduTrack SW] Registered scope:', r.scope); })

      .catch(function(e){ console.warn('[EduTrack SW] Registration failed:', e.message); });

  });

}



// ── 5. Install prompt handling ─────────────────────────────────────────────

var _deferredPrompt = null;



window.addEventListener('beforeinstallprompt', function(e){

  e.preventDefault();

  _deferredPrompt = e;

  var w = document.getElementById('pwaInstallWrap');

  if (w) w.style.display = 'block';

});



window.addEventListener('appinstalled', function(){

  _deferredPrompt = null;

  var w = document.getElementById('pwaInstallWrap');

  if (w) w.style.display = 'none';

  console.log('[EduTrack] App installed');

});



document.addEventListener('DOMContentLoaded', function(){

  var btn = document.getElementById('pwaInstallBtn');

  if (btn) {

    btn.addEventListener('click', function(){

      if (!_deferredPrompt) return;

      _deferredPrompt.prompt();

      _deferredPrompt.userChoice.then(function(){

        _deferredPrompt = null;

        var w = document.getElementById('pwaInstallWrap');

        if (w) w.style.display = 'none';

      });

    });

  }



  // ── 6. iOS "Add to Home Screen" hint ──────────────────────────────────────

  var isIOS      = /iphone|ipad|ipod/i.test(navigator.userAgent);

  var isSafari   = /safari/i.test(navigator.userAgent) && !/chrome|crios|fxios/i.test(navigator.userAgent);

  var isStandalone = window.navigator.standalone === true ||

                     window.matchMedia('(display-mode: standalone)').matches;

  if (isIOS && isSafari && !isStandalone && !sessionStorage.getItem('iosHintDone')) {

    var toast = document.createElement('div');

    toast.style.cssText = [

      'position:fixed','bottom:24px','left:50%','transform:translateX(-50%)',

      'background:#1e3a5f','color:#fff','padding:12px 16px','border-radius:14px',

      'font-size:13px','z-index:9999','max-width:310px','width:calc(100% - 32px)',

      'text-align:center','box-shadow:0 4px 24px rgba(0,0,0,.35)','line-height:1.5'

    ].join(';');

    toast.innerHTML =

      '<i class="fas fa-info-circle" style="margin-right:6px;color:#90cdf4"></i>' +

      'Install EduTrack: tap <strong>Share &#x2197;</strong> → <strong>Add to Home Screen</strong>' +

      '<button onclick="this.closest(\'div\').remove();sessionStorage.setItem(\'iosHintDone\',1)" ' +

      'style="background:none;border:none;color:#90cdf4;margin-left:10px;cursor:pointer;font-size:13px;padding:0">' +

      '&#10005;</button>';

    document.body.appendChild(toast);

    setTimeout(function(){ if (toast.parentNode) toast.remove(); }, 12000);

  }



  // ── 7. Offline / online banner ─────────────────────────────────────────────

  function offlineBanner(show){

    var existing = document.getElementById('pwaOfflineBanner');

    if (show && !existing) {

      var bar = document.createElement('div');

      bar.id = 'pwaOfflineBanner';

      bar.style.cssText = [

        'position:sticky','top:0','z-index:1050','background:#fef3c7',

        'color:#92400e','padding:6px 16px','font-size:13px',

        'text-align:center','border-bottom:1px solid #fde68a'

      ].join(';');

      bar.innerHTML = '<i class="fas fa-wifi" style="margin-right:6px"></i>' +

                      'You are offline — showing cached data. Changes will not be saved.';

      document.body.insertBefore(bar, document.body.firstChild);

    } else if (!show && existing) {

      existing.remove();

    }

  }

  offlineBanner(!navigator.onLine);

  window.addEventListener('offline', function(){ offlineBanner(true); });

  window.addEventListener('online',  function(){ offlineBanner(false); });

});

})();

</script>

PWASCRIPT;



    return $html;

    } catch (Throwable $e) {

        error_log('buildSidebar error: ' . $e->getMessage());

        return '<ul class="nav flex-column"><li class="nav-item"><a href="dashboard.php" class="nav-link"><i class="fas fa-home me-2"></i>Dashboard</a></li></ul>';

    }

}



// ── Attendance / School Calendar Helpers ───────────────────────────────────────



/**

 * Count weekday school days between two dates, excluding observed public holidays.

 * Returns integer or 0 on failure.

 */

if (!function_exists('countSchoolDays')) {

    function countSchoolDays(mysqli $conn, string $startDate, string $endDate): int {

        if ($startDate > $endDate) return 0;



        $stmt = $conn->prepare(

            "SELECT holiday_date FROM school_holidays

              WHERE is_observed = 1 AND holiday_date BETWEEN ? AND ?"

        );

        if (!$stmt) return 0;

        $stmt->bind_param('ss', $startDate, $endDate);

        $stmt->execute();

        $rows      = $stmt->get_result()->fetch_all(MYSQLI_ASSOC);

        $stmt->close();

        $holidays  = array_column($rows, 'holiday_date');



        $count   = 0;

        $current = new DateTime($startDate);

        $end     = new DateTime($endDate);

        while ($current <= $end) {

            $dow = (int) $current->format('N'); // 1 Mon … 7 Sun

            $ymd = $current->format('Y-m-d');

            if ($dow <= 5 && !in_array($ymd, $holidays, true)) {

                $count++;

            }

            $current->modify('+1 day');

        }

        return $count;

    }

}



/**

 * Return total school days for a given term using school_terms + school_holidays tables.

 * Falls back to the hardcoded value if no term record exists.

 */

if (!function_exists('getTermSchoolDays')) {

    function getTermSchoolDays(mysqli $conn, string $academicYear, string $term, int $fallback = 56): int {

        $stmt = $conn->prepare(

            "SELECT start_date, end_date FROM school_terms

              WHERE academic_year = ? AND term = ? LIMIT 1"

        );

        if (!$stmt) return $fallback;

        $stmt->bind_param('ss', $academicYear, $term);

        $stmt->execute();

        $row = $stmt->get_result()->fetch_assoc();

        $stmt->close();



        if (!$row) return $fallback;

        $days = countSchoolDays($conn, $row['start_date'], $row['end_date']);

        return $days > 0 ? $days : $fallback;

    }

}



/**

 * Count days a student was present/late during a term from the attendance table.

 * 'present' and 'late' both count as attended.

 */

if (!function_exists('getStudentTermDaysPresent')) {

    function getStudentTermDaysPresent(

        mysqli $conn,

        int    $studentId,

        string $academicYear,

        string $term

    ): int {

        // Get term dates

        $stmt = $conn->prepare(

            "SELECT start_date, end_date FROM school_terms

              WHERE academic_year = ? AND term = ? LIMIT 1"

        );

        if (!$stmt) return 0;

        $stmt->bind_param('ss', $academicYear, $term);

        $stmt->execute();

        $termRow = $stmt->get_result()->fetch_assoc();

        $stmt->close();



        if (!$termRow) return 0;



        $stmt = $conn->prepare(

            "SELECT COUNT(*) AS cnt FROM attendance

              WHERE student_id = ?

                AND attendance_date BETWEEN ? AND ?

                AND status IN ('present','late')"

        );

        if (!$stmt) return 0;

        $stmt->bind_param('iss', $studentId, $termRow['start_date'], $termRow['end_date']);

        $stmt->execute();

        $row = $stmt->get_result()->fetch_assoc();

        $stmt->close();

        return (int) ($row['cnt'] ?? 0);

    }

}

