<?php
declare(strict_types=1);

function e(?string $value): string { return htmlspecialchars((string)$value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); }
function db(): PDO { global $pdo; return $pdo; }
function cfg(string $key, mixed $default = null): mixed {
    global $config;
    $value = $config;
    foreach (explode('.', $key) as $part) {
        if (!is_array($value) || !array_key_exists($part, $value)) return $default;
        $value = $value[$part];
    }
    return $value;
}
function url(string $path = ''): string { return rtrim((string)cfg('app.base_url', ''), '/') . '/' . ltrim($path, '/'); }
function redirect(string $path): never {
    if (!str_starts_with($path, 'http')) {
        header('Location: ' . url($path));
        exit;
    }
    $target = parse_url($path);
    $app = parse_url((string)cfg('app.base_url', ''));
    $host = strtolower((string)($target['host'] ?? ''));
    $allowed = strtolower((string)($target['scheme'] ?? '')) === 'https'
        && ($host === strtolower((string)($app['host'] ?? '')) || $host === 'wa.me');
    header('Location: ' . ($allowed ? $path : url('index.php')));
    exit;
}
function safe_return_path(?string $value, string $fallback = 'index.php'): string {
    $value = trim((string)$value);
    if ($value === '' || str_contains($value, "\r") || str_contains($value, "\n")) return $fallback;
    if (!str_starts_with($value, 'http')) return ltrim($value, '/');
    $target = parse_url($value);
    $app = parse_url((string)cfg('app.base_url', ''));
    if (strtolower((string)($target['scheme'] ?? '')) !== 'https'
        || strtolower((string)($target['host'] ?? '')) !== strtolower((string)($app['host'] ?? ''))) return $fallback;
    return ltrim((string)($target['path'] ?? 'index.php'), '/')
        . (!empty($target['query']) ? '?' . $target['query'] : '');
}
function user(): ?array {
    static $checked = false;
    static $account = null;
    if ($checked) return $account;
    $checked = true;
    $sessionUser = $_SESSION['user'] ?? null;
    if (!$sessionUser || empty($sessionUser['id'])) return null;
    $now = time();
    $lastActivity = (int)($_SESSION['last_activity_at'] ?? $now);
    if ($now - $lastActivity > 8 * 3600) {
        unset($_SESSION['user'], $_SESSION['csrf'], $_SESSION['last_activity_at'], $_SESSION['session_rotated_at']);
        return null;
    }
    $_SESSION['last_activity_at'] = $now;
    if ($now - (int)($_SESSION['session_rotated_at'] ?? 0) > 30 * 60) {
        session_regenerate_id(true);
        $_SESSION['session_rotated_at'] = $now;
    }
    try {
        $stmt = db()->prepare('SELECT id,name,email,role,active,session_version FROM users WHERE id=? LIMIT 1');
        $stmt->execute([(int)$sessionUser['id']]);
        $fresh = $stmt->fetch();
    } catch (Throwable $ignored) {
        // Keeps the owner signed in long enough to run the explicit upgrade once.
        $stmt = db()->prepare('SELECT id,name,email,role,active FROM users WHERE id=? LIMIT 1');
        $stmt->execute([(int)$sessionUser['id']]);
        $fresh = $stmt->fetch();
        if ($fresh) $fresh['session_version'] = 1;
    }
    $sessionVersion = (int)($sessionUser['session_version'] ?? 1);
    if (!$fresh || !(int)$fresh['active'] || (int)$fresh['session_version'] !== $sessionVersion) {
        unset($_SESSION['user'], $_SESSION['csrf'], $_SESSION['last_activity_at'], $_SESSION['session_rotated_at']);
        return null;
    }
    $account = $fresh;
    $_SESSION['user'] = $fresh;
    return $account;
}
function require_auth(): void { if (!user()) redirect('login.php'); }
function require_role(array $roles): void { require_auth(); if (!in_array(user()['role'], $roles, true)) { http_response_code(403); exit('You do not have permission to perform this action.'); } }
function require_current_schema(): void {
    require_auth();
    if (billing_schema_is_current()) return;
    if ((user()['role'] ?? '') === 'owner') redirect('upgrade.php');
    http_response_code(503);
    exit('Soda ZW Billing is undergoing an owner-controlled database upgrade. Please try again shortly.');
}
function csrf_token(): string { if (empty($_SESSION['csrf'])) $_SESSION['csrf'] = bin2hex(random_bytes(32)); return $_SESSION['csrf']; }
function csrf_field(): string { return '<input type="hidden" name="csrf" value="' . e(csrf_token()) . '">'; }
function verify_csrf(): void { if (!hash_equals($_SESSION['csrf'] ?? '', (string)($_POST['csrf'] ?? ''))) { http_response_code(419); exit('Your session expired. Please return and try again.'); } }
function flash(string $type, string $message): void { $_SESSION['flash'][] = compact('type', 'message'); }
function take_flashes(): array { $items = $_SESSION['flash'] ?? []; unset($_SESSION['flash']); return $items; }
function settings(bool $refresh = false): array {
    static $value;
    if ($refresh || !$value) $value = db()->query('SELECT * FROM settings WHERE id = 1')->fetch() ?: [];
    return $value;
}
function money(float|string $amount, ?string $currency = null): string { return ($currency ?? settings()['currency_code'] ?? 'USD') . ' ' . number_format((float)$amount, 2); }
function decimal(mixed $value): float { return round((float)str_replace(',', '', (string)$value), 2); }
function audit(string $action, ?string $entityType = null, ?int $entityId = null, ?string $details = null): void {
    $stmt = db()->prepare('INSERT INTO audit_logs (user_id, action, entity_type, entity_id, details, ip_address) VALUES (?, ?, ?, ?, ?, ?)');
    $stmt->execute([user()['id'] ?? null, $action, $entityType, $entityId, $details, $_SERVER['REMOTE_ADDR'] ?? null]);
}
function document_label(string $type): string { return match ($type) { 'quotation' => 'Quotation', 'credit_note' => 'Credit note', default => 'Invoice' }; }
function document_balance(int $invoiceId): float {
    if(function_exists('invoice_financials'))return invoice_financials($invoiceId)['balance'];
    $stmt = db()->prepare('SELECT d.total - COALESCE(SUM(p.amount),0) balance FROM documents d LEFT JOIN payments p ON p.invoice_id=d.id WHERE d.id=? GROUP BY d.id');$stmt->execute([$invoiceId]);return max(0,(float)($stmt->fetchColumn()?:0));
}

function invoice_payment_label(float $total, float $paid): string {
    return max(0, $total - $paid) <= 0.009 ? 'PAID' : 'UNPAID';
}

function invoice_payment_detail(float $total, float $paid, ?string $dueDate = null): string {
    $balance = max(0, $total - $paid);
    if ($balance <= 0.009) return 'Paid in full';
    if ($paid > 0) return 'Partially paid';
    if ($dueDate && $dueDate < date('Y-m-d')) return 'Overdue';
    return 'Payment due';
}
function update_invoice_status(int $invoiceId): void {
    $stmt = db()->prepare('SELECT total, due_date, status FROM documents WHERE id=? AND type="invoice"');
    $stmt->execute([$invoiceId]); $invoice = $stmt->fetch(); if (!$invoice || $invoice['status'] === 'cancelled') return;
    $paid=function_exists('invoice_payment_total')?invoice_payment_total($invoiceId):0.0;$credit=function_exists('invoice_credit_total')?invoice_credit_total($invoiceId):0.0;
    $status = $paid+$credit >= (float)$invoice['total']-.009 ? 'paid' : ($paid+$credit > 0 ? 'partial' : ((!empty($invoice['due_date']) && $invoice['due_date'] < date('Y-m-d')) ? 'overdue' : 'sent'));
    db()->prepare('UPDATE documents SET status=? WHERE id=?')->execute([$status, $invoiceId]);
    $refillStatus = $status === 'paid' ? 'paid' : ($paid > 0 ? 'partial' : 'unpaid');
    db()->prepare('UPDATE refill_jobs SET payment_status=? WHERE document_id=?')->execute([$refillStatus, $invoiceId]);
}
function reserve_number(string $type): string {
    $column = $type === 'quotation' ? 'next_quote_number' : ($type==='credit_note'?'next_credit_number':'next_invoice_number');
    $prefix = $type === 'quotation' ? 'quote_prefix' : ($type==='credit_note'?'credit_prefix':'invoice_prefix');
    $row = db()->query("SELECT $column, $prefix FROM settings WHERE id=1 FOR UPDATE")->fetch();
    $number = (int)$row[$column];
    db()->exec("UPDATE settings SET $column = $column + 1 WHERE id=1");
    return $row[$prefix] . date('Y') . '-' . str_pad((string)$number, 4, '0', STR_PAD_LEFT);
}
function reserve_unique_number(string $type): string {
    for ($attempt = 0; $attempt < 10000; $attempt++) {
        $number = reserve_number($type);
        $check = db()->prepare('SELECT 1 FROM documents WHERE number=? LIMIT 1');
        $check->execute([$number]);
        if (!$check->fetchColumn()) return $number;
    }
    throw new RuntimeException('A unique document number could not be reserved. Check the numbering settings.');
}
function document_totals(array $items, string $discountType, float $discountValue, float $deliveryFee): array {
    $subtotal = 0.0; $clean = [];
    foreach ($items as $item) {
        $description = trim((string)($item['description'] ?? '')); if ($description === '') continue;
        $quantity = max(0.01, decimal($item['quantity'] ?? 1)); $price = max(0, decimal($item['unit_price'] ?? 0)); $rate = max(0, decimal($item['tax_rate'] ?? 0));
        $lineSubtotal = round($quantity * $price, 2);
        $clean[] = ['product_id' => !empty($item['product_id']) ? (int)$item['product_id'] : null, 'description'=>$description, 'quantity'=>$quantity, 'unit_price'=>$price, 'tax_rate'=>$rate, 'line_subtotal'=>$lineSubtotal, 'line_tax'=>0.0, 'line_total'=>$lineSubtotal];
        $subtotal += $lineSubtotal;
    }
    $discount = $discountType === 'percent' ? round($subtotal * min(100, $discountValue) / 100, 2) : min($subtotal, $discountValue);
    $tax=0.0;$discountRatio=$subtotal>0?$discount/$subtotal:0.0;
    foreach($clean as &$line){$taxable=max(0,(float)$line['line_subtotal']*(1-$discountRatio));$line['line_tax']=round($taxable*(float)$line['tax_rate']/100,2);$line['line_total']=(float)$line['line_subtotal']+$line['line_tax'];$tax+=$line['line_tax'];}unset($line);
    return ['items'=>$clean, 'subtotal'=>$subtotal, 'discount_total'=>$discount, 'tax_total'=>$tax, 'delivery_fee'=>$deliveryFee, 'total'=>max(0, $subtotal-$discount+$tax+$deliveryFee)];
}
