<?php
function e($value): string
{
    return htmlspecialchars((string)$value, ENT_QUOTES, 'UTF-8');
}

function app_name(): string
{
    return defined('APP_NAME') ? APP_NAME : 'SOS Tiketing';
}

function app_org(): string
{
    return defined('APP_ORGANIZATION') ? APP_ORGANIZATION : 'SOS Dečija sela Srbija';
}

function base_url(): string
{
    $configured = defined('BASE_URL') ? trim((string)BASE_URL) : '';

    if ($configured !== '' && strtolower($configured) !== 'auto') {
        return rtrim($configured, '/');
    }

    $scriptName = str_replace('\\', '/', (string)($_SERVER['SCRIPT_NAME'] ?? ''));
    $dir = rtrim(str_replace('\\', '/', dirname($scriptName)), '/');
    if ($dir === '/' || $dir === '.' || $dir === '') {
        return '';
    }

    return $dir;
}

function url(string $path = ''): string
{
    $path = ltrim($path, '/');

    // Relativne putanje su najstabilnije na cPanel hostingu, jer rade isto u root-u
    // domena i u podfolderu. Time se izbegava najčešći uzrok "razbijenog" dizajna:
    // pogrešno podešen BASE_URL. Ako je izričito potrebno, može se uključiti apsolutni
    // režim preko USE_ABSOLUTE_BASE_URL = true u config.php.
    $useAbsolute = defined('USE_ABSOLUTE_BASE_URL') && USE_ABSOLUTE_BASE_URL === true;
    if (!$useAbsolute) {
        return $path === '' ? './' : $path;
    }

    $base = base_url();
    if ($path === '') {
        return $base === '' ? '/' : $base . '/';
    }
    return ($base === '' ? '' : $base) . '/' . $path;
}

function redirect(string $path): void
{
    header('Location: ' . url(safe_internal_path($path, 'dashboard.php')));
    exit;
}

function safe_internal_path(string $path, string $fallback = 'dashboard.php'): string
{
    $path = trim($path);
    if ($path === '') {
        return $fallback;
    }
    if (preg_match('/[\x00-\x1F\x7F\\\\]/', $path) || str_starts_with($path, '//')) {
        return $fallback;
    }

    $parts = parse_url($path);
    if ($parts === false || isset($parts['scheme']) || isset($parts['host']) || isset($parts['user']) || isset($parts['pass']) || isset($parts['port'])) {
        return $fallback;
    }
    $pathPart = (string)($parts['path'] ?? '');
    if ($pathPart === '' || str_starts_with($pathPart, '/')) {
        return $fallback;
    }
    foreach (explode('/', $pathPart) as $segment) {
        if ($segment === '.' || $segment === '..') {
            return $fallback;
        }
    }
    return $path;
}

function is_post(): bool
{
    return strtoupper($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'POST';
}

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

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

function validate_csrf(): void
{
    $sent = $_POST['csrf_token'] ?? '';
    if (!csrf_token_is_valid((string)$sent)) {
        http_response_code(419);
        exit('Bezbednosni token je istekao ili nije validan. Vratite se nazad i pokušajte ponovo.');
    }
}

function csrf_token_is_valid(string $sent): bool
{
    return $sent !== '' && isset($_SESSION['csrf_token']) && hash_equals((string)$_SESSION['csrf_token'], $sent);
}

function flash(string $type, string $message): void
{
    $_SESSION['flash'][] = ['type' => $type, 'message' => $message];
}

function flashes(): array
{
    $items = $_SESSION['flash'] ?? [];
    unset($_SESSION['flash']);
    return $items;
}

function current_user(): ?array
{
    if (empty($_SESSION['user_id'])) {
        return null;
    }

    static $user = null;
    if ($user !== null) {
        return $user;
    }

    try {
        $stmt = db()->prepare('SELECT id, name, email, is_system_admin, is_active, must_change_password, created_at, updated_at, job_title, phone, location, manager_name, department, employee_code, last_login_at, profile_photo FROM users WHERE id = ? LIMIT 1');
        $stmt->execute([$_SESSION['user_id']]);
    } catch (PDOException $e) {
        // Fallback postoji samo da bi se prikazala kontrolisana greška pre migracije.
        $stmt = db()->prepare('SELECT id, name, email, is_active, created_at FROM users WHERE id = ? LIMIT 1');
        $stmt->execute([$_SESSION['user_id']]);
    }

    $user = $stmt->fetch();

    if (!$user || (int)$user['is_active'] !== 1) {
        $_SESSION = [];
        session_destroy();
        return null;
    }

    foreach (['job_title', 'phone', 'location', 'manager_name', 'department', 'employee_code', 'last_login_at', 'profile_photo', 'must_change_password', 'is_system_admin'] as $key) {
        if (!array_key_exists($key, $user)) {
            $user[$key] = null;
        }
    }
    $user['role_codes'] = user_role_codes((int)$user['id']);

    return $user;
}

function require_auth(): array
{
    $user = current_user();
    if (!$user) {
        redirect('login.php');
    }
    return $user;
}

function business_role_definitions(): array
{
    static $definitions = null;
    if (is_array($definitions)) {
        return $definitions;
    }

    try {
        $stmt = db()->query('SELECT role_code, name FROM roles WHERE is_active = 1 ORDER BY sort_order ASC, name ASC');
        $definitions = [];
        foreach ($stmt->fetchAll() as $row) {
            $code = strtoupper(trim((string)($row['role_code'] ?? '')));
            $name = trim((string)($row['name'] ?? ''));
            if ($code !== '' && $name !== '') {
                $definitions[$code] = $name;
            }
        }
        if ($definitions) {
            return $definitions;
        }
    } catch (Throwable $e) {
        // Kontrolisani fallback omogućava prikaz pre završene migracije baze.
    }

    return $definitions = [
        'USERF' => 'Zaposleni — Fondacija',
        'USERDS' => 'Zaposleni — Dečije selo',
        'WHF' => 'Magacioner — Fondacija',
        'WHDS' => 'Magacioner — Dečije selo',
        'OFCF' => 'Koordinator službenog prostora i opreme — Fondacija',
        'OFCDS' => 'Koordinator službenog prostora i opreme — Dečije selo',
        'VHCF' => 'Koordinator službenih vozila — Fondacija',
        'VHCDS' => 'Koordinator službenih vozila — Dečije selo',
        'ICTF' => 'ICT podrška — Fondacija',
        'ICTDS' => 'ICT podrška — Dečije selo',
        'OTHF' => 'Koordinator za razvrstavanje zahteva — Fondacija',
        'OTHDS' => 'Koordinator za razvrstavanje zahteva — Dečije selo',
    ];
}

function organization_unit_definitions(): array
{
    return [
        'F' => 'Fondacija',
        'DS' => 'Dečije selo',
    ];
}

function equipment_manager_role_codes(): array
{
    return ['WHF', 'WHDS'];
}

function equipment_organization_unit_label(?string $organizationUnit): string
{
    $organizationUnit = strtoupper(trim((string)$organizationUnit));
    return organization_unit_definitions()[$organizationUnit] ?? 'Zahteva ručnu proveru';
}

function base_user_role_code(): string
{
    // Legacy helper retained for backwards compatibility with older integrations.
    return 'USERF';
}

function base_user_role_codes(): array
{
    return ['USERF', 'USERDS'];
}

function role_organization_unit(string $roleCode): ?string
{
    $roleCode = strtoupper(trim($roleCode));
    if ($roleCode === '') {
        return null;
    }

    // Check longer suffixes first so future multi-character units remain unambiguous.
    $units = array_keys(organization_unit_definitions());
    usort($units, static fn(string $a, string $b): int => strlen($b) <=> strlen($a));
    foreach ($units as $unit) {
        if (str_ends_with($roleCode, $unit) && strlen($roleCode) > strlen($unit)) {
            return $unit;
        }
    }
    return null;
}

function role_functional_code(string $roleCode): string
{
    $roleCode = strtoupper(trim($roleCode));
    $unit = role_organization_unit($roleCode);
    if ($unit === null) {
        return $roleCode;
    }
    return substr($roleCode, 0, -strlen($unit));
}

function role_code_for_unit(string $roleCode, string $organizationUnit): string
{
    $organizationUnit = strtoupper(trim($organizationUnit));
    if (!isset(organization_unit_definitions()[$organizationUnit])) {
        throw new InvalidArgumentException('Nepoznata organizaciona jedinica.');
    }

    $functionalCode = role_functional_code($roleCode);
    if ($functionalCode === '') {
        throw new InvalidArgumentException('Nevalidan kod poslovne uloge.');
    }
    return $functionalCode . $organizationUnit;
}

function user_organization_unit(array $user): ?string
{
    $codes = isset($user['role_codes']) && is_array($user['role_codes'])
        ? $user['role_codes']
        : user_role_codes((int)($user['id'] ?? 0));
    $units = [];
    foreach (base_user_role_codes() as $baseCode) {
        if (in_array($baseCode, $codes, true)) {
            $unit = role_organization_unit($baseCode);
            if ($unit !== null) {
                $units[$unit] = $unit;
            }
        }
    }
    return count($units) === 1 ? array_values($units)[0] : null;
}

function ticket_organization_unit(array $ticket): ?string
{
    return role_organization_unit((string)($ticket['responsible_role_code'] ?? ''));
}

function processing_role_codes(): array
{
    $excludedCodes = array_merge(base_user_role_codes(), equipment_manager_role_codes());
    return array_values(array_filter(
        array_keys(business_role_definitions()),
        static fn(string $code): bool => !in_array($code, $excludedCodes, true)
    ));
}

function processing_role_codes_for_unit(string $organizationUnit): array
{
    $organizationUnit = strtoupper(trim($organizationUnit));
    return array_values(array_filter(
        processing_role_codes(),
        static fn(string $code): bool => role_organization_unit($code) === $organizationUnit
    ));
}

function category_processing_role_codes(): array
{
    // ticket_categories stores one canonical functional mapping. F is retained as the
    // canonical representation for backwards compatibility; ticket routing maps it
    // to F or DS at runtime according to the ticket organization unit.
    return processing_role_codes_for_unit('F');
}

function functional_role_label(string $roleCode): string
{
    $label = role_label($roleCode);
    foreach (organization_unit_definitions() as $unitLabel) {
        $suffix = ' — ' . $unitLabel;
        if (str_ends_with($label, $suffix)) {
            return substr($label, 0, -strlen($suffix));
        }
    }
    return $label;
}

function normalize_role_codes($value): array
{
    $allowed = business_role_definitions();
    $values = is_array($value) ? $value : [$value];
    $normalized = [];
    foreach ($values as $code) {
        $code = strtoupper(trim((string)$code));
        if (isset($allowed[$code])) {
            $normalized[$code] = $code;
        }
    }
    return array_values($normalized);
}

function user_role_codes(int $userId): array
{
    if ($userId <= 0) {
        return [];
    }
    try {
        $stmt = db()->prepare('SELECT r.role_code
            FROM user_roles ur
            JOIN roles r ON r.id = ur.role_id AND r.is_active = 1
            WHERE ur.user_id = ?
            ORDER BY r.sort_order ASC, r.name ASC');
        $stmt->execute([$userId]);
        return array_values(array_unique(array_map('strval', $stmt->fetchAll(PDO::FETCH_COLUMN))));
    } catch (Throwable $e) {
        return [];
    }
}

function user_has_role(array $user, string $roleCode): bool
{
    $roleCode = strtoupper(trim($roleCode));
    $codes = isset($user['role_codes']) && is_array($user['role_codes'])
        ? $user['role_codes']
        : user_role_codes((int)($user['id'] ?? 0));
    return in_array($roleCode, $codes, true);
}

function user_additional_role_codes(array $user): array
{
    $codes = isset($user['role_codes']) && is_array($user['role_codes'])
        ? $user['role_codes']
        : user_role_codes((int)($user['id'] ?? 0));

    // Magacionerske role su namensko pravo za inventar i ne daju pravo obrade
    // tiketa. Zato se eksplicitno izuzimaju iz postojećeg ticket-role skupa.
    $excludedCodes = array_merge(base_user_role_codes(), equipment_manager_role_codes());
    return array_values(array_filter(
        array_values(array_unique(array_map(static fn($code): string => strtoupper(trim((string)$code)), $codes))),
        static fn(string $code): bool => $code !== '' && !in_array($code, $excludedCodes, true)
    ));
}

function user_has_additional_role(array $user): bool
{
    return user_additional_role_codes($user) !== [];
}

function user_has_processing_role(array $user): bool
{
    // Zadržan naziv funkcije zbog kompatibilnosti sa postojećim pozivima.
    return user_has_additional_role($user);
}

function is_system_admin(?array $user = null): bool
{
    $user = $user ?? current_user();
    return $user !== null && (int)($user['is_system_admin'] ?? 0) === 1;
}

function require_system_admin(): array
{
    $user = require_auth();
    if (!is_system_admin($user)) {
        http_response_code(403);
        exit('Nemate sistemsko administratorsko pravo.');
    }
    return $user;
}

function equipment_manager_organization_unit(array $user): ?string
{
    if (is_system_admin($user)) {
        return null;
    }
    $units = [];
    foreach (equipment_manager_role_codes() as $roleCode) {
        if (user_has_role($user, $roleCode)) {
            $unit = role_organization_unit($roleCode);
            if ($unit !== null) {
                $units[$unit] = $unit;
            }
        }
    }
    if (count($units) !== 1) {
        return null;
    }
    $warehouseUnit = array_values($units)[0];
    return user_organization_unit($user) === $warehouseUnit ? $warehouseUnit : null;
}

function can_manage_equipment(?array $user = null): bool
{
    $user = $user ?? current_user();
    return $user !== null && (is_system_admin($user) || equipment_manager_organization_unit($user) !== null);
}

function require_equipment_manager(): array
{
    $user = require_auth();
    if (!can_manage_equipment($user)) {
        http_response_code(403);
        exit('Nemate pravo za upravljanje opremom.');
    }
    return $user;
}

function can_manage_equipment_item(array $user, array $equipment): bool
{
    if (is_system_admin($user)) {
        return true;
    }
    $allowedUnit = equipment_manager_organization_unit($user);
    return $allowedUnit !== null
        && hash_equals($allowedUnit, strtoupper(trim((string)($equipment['organization_unit'] ?? ''))));
}

function equipment_access_scope_sql(array $user, string $alias = 'e'): array
{
    if (is_system_admin($user)) {
        return ['1=1', []];
    }
    $unit = equipment_manager_organization_unit($user);
    return $unit !== null ? [$alias . '.organization_unit = ?', [$unit]] : ['0=1', []];
}

function validate_warehouse_role_consistency(array $roleCodes): array
{
    $roleCodes = array_values(array_unique(array_map(static fn($code): string => strtoupper(trim((string)$code)), $roleCodes)));
    $baseUnits = [];
    foreach (base_user_role_codes() as $baseRole) {
        if (in_array($baseRole, $roleCodes, true)) {
            $unit = role_organization_unit($baseRole);
            if ($unit !== null) {
                $baseUnits[$unit] = true;
            }
        }
    }
    foreach (equipment_manager_role_codes() as $warehouseRole) {
        if (!in_array($warehouseRole, $roleCodes, true)) {
            continue;
        }
        $unit = role_organization_unit($warehouseRole);
        if ($unit === null || count($baseUnits) !== 1 || !isset($baseUnits[$unit])) {
            return ['Magacionerska uloga mora odgovarati pravnom licu osnovne korisničke uloge.'];
        }
    }
    if (count(array_intersect($roleCodes, equipment_manager_role_codes())) > 1) {
        return ['Korisnik ne može istovremeno biti magacioner oba pravna lica.'];
    }
    return [];
}

function can_create_ticket(array $user): bool
{
    if (is_system_admin($user)) {
        return true;
    }
    foreach (base_user_role_codes() as $baseCode) {
        if (user_has_role($user, $baseCode)) {
            return true;
        }
    }
    return false;
}

function user_role_labels(array $user): array
{
    $labels = [];
    foreach (($user['role_codes'] ?? user_role_codes((int)($user['id'] ?? 0))) as $code) {
        $labels[] = role_label((string)$code);
    }
    return $labels;
}

function user_roles_summary(array $user): string
{
    $labels = user_role_labels($user);
    if (is_system_admin($user)) {
        $labels[] = 'Sistemski administrator';
    }
    return $labels ? implode(', ', array_values(array_unique($labels))) : 'Bez dodeljene uloge';
}

function user_roles_compact_summary(array $user): string
{
    if (is_system_admin($user)) {
        return 'Sistemski administrator';
    }
    $labels = user_role_labels($user);
    if (!$labels) {
        return 'Bez dodeljene uloge';
    }
    return $labels[0] . (count($labels) > 1 ? ' +' . (count($labels) - 1) : '');
}

function sync_user_roles(PDO $pdo, int $targetUserId, array $requestedCodes, int $changedBy): void
{
    $requestedCodes = normalize_role_codes($requestedCodes);
    $warehouseRoleErrors = validate_warehouse_role_consistency($requestedCodes);
    if ($warehouseRoleErrors) {
        throw new InvalidArgumentException($warehouseRoleErrors[0]);
    }
    $selectedBaseRoles = array_values(array_intersect($requestedCodes, base_user_role_codes()));
    if (count($selectedBaseRoles) > 1) {
        throw new InvalidArgumentException('Korisnik može imati samo jednu osnovnu organizacionu ulogu: USERF ili USERDS.');
    }
    $changedByValue = $changedBy > 0 ? $changedBy : null;
    $stmt = $pdo->prepare('SELECT ur.role_id, r.role_code
        FROM user_roles ur JOIN roles r ON r.id = ur.role_id
        WHERE ur.user_id = ?');
    $stmt->execute([$targetUserId]);
    $current = [];
    foreach ($stmt->fetchAll() as $row) {
        $current[(string)$row['role_code']] = (int)$row['role_id'];
    }

    $roleStmt = $pdo->query('SELECT id, role_code FROM roles WHERE is_active = 1');
    $roleIds = [];
    foreach ($roleStmt->fetchAll() as $row) {
        $roleIds[(string)$row['role_code']] = (int)$row['id'];
    }

    $insert = $pdo->prepare('INSERT INTO user_roles (user_id, role_id, created_at, created_by) VALUES (?, ?, NOW(), ?)');
    $event = $pdo->prepare('INSERT INTO user_role_events (target_user_id, role_id, changed_by, action, created_at) VALUES (?, ?, ?, ?, NOW())');
    foreach (array_diff($requestedCodes, array_keys($current)) as $code) {
        if (!isset($roleIds[$code])) {
            continue;
        }
        $insert->execute([$targetUserId, $roleIds[$code], $changedByValue]);
        $event->execute([$targetUserId, $roleIds[$code], $changedByValue, 'added']);
    }

    $delete = $pdo->prepare('DELETE FROM user_roles WHERE user_id = ? AND role_id = ?');
    foreach (array_diff(array_keys($current), $requestedCodes) as $code) {
        if (in_array($code, processing_role_codes(), true)) {
            $assignmentEvent = $pdo->prepare("INSERT INTO ticket_events (ticket_id, user_id, event_type, old_value, new_value, created_at)
                SELECT id, ?, 'assigned_changed', CAST(assigned_to AS CHAR), NULL, NOW()
                FROM tickets
                WHERE assigned_to = ? AND responsible_role_code = ?");
            $assignmentEvent->execute([$changedByValue ?? $targetUserId, $targetUserId, $code]);
            $clearAssignment = $pdo->prepare('UPDATE tickets SET assigned_to = NULL, updated_at = NOW() WHERE assigned_to = ? AND responsible_role_code = ?');
            $clearAssignment->execute([$targetUserId, $code]);
        }
        $delete->execute([$targetUserId, $current[$code]]);
        $event->execute([$targetUserId, $current[$code], $changedByValue, 'removed']);
    }
}

function status_options(): array
{
    return [
        'open' => 'Otvoren',
        'in_progress' => 'U obradi',
        'waiting' => 'Na čekanju',
        'resolved' => 'Rešen',
        'closed' => 'Zatvoren',
    ];
}

function priority_options(): array
{
    static $options = null;
    if (is_array($options)) {
        return $options;
    }
    $options = [];
    foreach (ticket_priority_records(false) as $row) {
        $options[(string)$row['priority_key']] = (string)$row['name'];
    }
    return $options;
}

function ticket_form_priority_options(): array
{
    static $options = null;
    if (is_array($options)) {
        return $options;
    }
    $options = [];
    foreach (ticket_priority_records(true) as $row) {
        $label = trim((string)$row['name']);
        if (trim((string)($row['description'] ?? '')) !== '') {
            $label .= ' – ' . trim((string)$row['description']);
        }
        $options[(string)$row['priority_key']] = $label;
    }
    return $options;
}

function category_options(): array
{
    static $options = null;
    if (is_array($options)) {
        return $options;
    }
    $options = [];
    foreach (ticket_category_records(true) as $row) {
        $options[(string)$row['name']] = (string)$row['name'];
    }
    return $options;
}

function ticket_location_options(): array
{
    static $options = null;
    if (is_array($options)) {
        return $options;
    }
    $options = [];
    foreach (ticket_location_records(true) as $row) {
        $options[(string)$row['id']] = (string)$row['name'];
    }
    return $options;
}

function ticket_category_records(bool $activeOnly = true): array
{
    static $cache = [];
    $cacheKey = $activeOnly ? 'active' : 'all';
    if (isset($cache[$cacheKey])) {
        return $cache[$cacheKey];
    }
    try {
        $sql = 'SELECT id, name, ticket_code, responsible_role_code, sort_order, is_active FROM ticket_categories';
        if ($activeOnly) {
            $sql .= ' WHERE is_active = 1';
        }
        $sql .= ' ORDER BY sort_order ASC, name ASC';
        return $cache[$cacheKey] = db()->query($sql)->fetchAll();
    } catch (Throwable $e) {
        return $cache[$cacheKey] = [
            ['id' => 1, 'name' => 'Službeni prostor', 'ticket_code' => '2', 'responsible_role_code' => 'OFCF', 'sort_order' => 10, 'is_active' => 1],
            ['id' => 2, 'name' => 'Oprema službenog prostora', 'ticket_code' => '2', 'responsible_role_code' => 'OFCF', 'sort_order' => 20, 'is_active' => 1],
            ['id' => 3, 'name' => 'Službena vozila', 'ticket_code' => '3', 'responsible_role_code' => 'VHCF', 'sort_order' => 30, 'is_active' => 1],
            ['id' => 4, 'name' => 'ICT oprema', 'ticket_code' => '1', 'responsible_role_code' => 'ICTF', 'sort_order' => 40, 'is_active' => 1],
            ['id' => 5, 'name' => 'Ostalo', 'ticket_code' => '4', 'responsible_role_code' => 'OTHF', 'sort_order' => 50, 'is_active' => 1],
        ];
    }
}

function ticket_priority_records(bool $activeOnly = true): array
{
    static $cache = [];
    $cacheKey = $activeOnly ? 'active' : 'all';
    if (isset($cache[$cacheKey])) {
        return $cache[$cacheKey];
    }
    try {
        $sql = 'SELECT id, priority_key, name, description, sort_order, is_active FROM ticket_priorities';
        if ($activeOnly) {
            $sql .= ' WHERE is_active = 1';
        }
        $sql .= ' ORDER BY sort_order ASC, name ASC';
        return $cache[$cacheKey] = db()->query($sql)->fetchAll();
    } catch (Throwable $e) {
        return $cache[$cacheKey] = [
            ['id' => 1, 'priority_key' => 'low', 'name' => 'Nizak', 'description' => 'rešava se u roku od 15 radnih dana', 'sort_order' => 10, 'is_active' => 1],
            ['id' => 2, 'priority_key' => 'normal', 'name' => 'Srednji', 'description' => 'rešava se u roku od 10 radnih dana', 'sort_order' => 20, 'is_active' => 1],
            ['id' => 3, 'priority_key' => 'high', 'name' => 'Visok', 'description' => 'rešava se u roku od 5 radnih dana', 'sort_order' => 30, 'is_active' => 1],
            ['id' => 4, 'priority_key' => 'urgent', 'name' => 'Hitno', 'description' => 'odmah po hitnom postupku', 'sort_order' => 40, 'is_active' => 1],
        ];
    }
}

function ticket_location_records(bool $activeOnly = true): array
{
    static $cache = [];
    $cacheKey = $activeOnly ? 'active' : 'all';
    if (isset($cache[$cacheKey])) {
        return $cache[$cacheKey];
    }
    try {
        $sql = 'SELECT id, name, sort_order, is_active FROM ticket_locations';
        if ($activeOnly) {
            $sql .= ' WHERE is_active = 1';
        }
        $sql .= ' ORDER BY sort_order ASC, name ASC';
        return $cache[$cacheKey] = db()->query($sql)->fetchAll();
    } catch (Throwable $e) {
        $configured = defined('TICKET_LOCATION_OPTIONS') && is_array(TICKET_LOCATION_OPTIONS) ? TICKET_LOCATION_OPTIONS : [];
        $rows = [];
        $order = 10;
        foreach ($configured as $name) {
            $rows[] = ['id' => $order / 10, 'name' => (string)$name, 'sort_order' => $order, 'is_active' => 1];
            $order += 10;
        }
        return $cache[$cacheKey] = $rows;
    }
}

function legacy_category_options(): array
{
    return [
        'IT podrška' => 'IT podrška',
        'ICT podrška' => 'ICT podrška',
        'Održavanje prostora' => 'Održavanje prostora',
        'Vozila' => 'Vozila',
        'Hardver' => 'Hardver',
        'Softver' => 'Softver',
        'Mreža i internet' => 'Mreža i internet',
        'Microsoft 365' => 'Microsoft 365',
        'Nalog i pristup' => 'Nalog i pristup',
        'Održavanje' => 'Održavanje',
        'Logistika' => 'Logistika',
    ];
}

function all_category_options(): array
{
    $options = [];
    foreach (ticket_category_records(false) as $row) {
        $options[(string)$row['name']] = (string)$row['name'];
    }
    return $options + legacy_category_options();
}

function ticket_category_code(string $category): string
{
    foreach (ticket_category_records(false) as $row) {
        if ((string)$row['name'] === $category && in_array((string)$row['ticket_code'], ['1', '2', '3', '4'], true)) {
            return (string)$row['ticket_code'];
        }
    }

    $normalized = mb_strtolower(trim($category), 'UTF-8');

    $itCategories = [
        'it podrška',
        'ict podrška',
        'ict oprema',
        'hardver',
        'softver',
        'mreža i internet',
        'microsoft 365',
        'nalog i pristup',
    ];
    if (in_array($normalized, $itCategories, true)) {
        return '1';
    }
    if (in_array($normalized, ['službeni prostor', 'sluzbeni prostor', 'oprema službenog prostora', 'oprema sluzbenog prostora', 'održavanje prostora', 'odrzavanje prostora', 'održavanje', 'odrzavanje'], true)) {
        return '2';
    }
    if (in_array($normalized, ['vozila', 'vozilo', 'službena vozila', 'sluzbena vozila'], true)) {
        return '3';
    }

    return '4';
}

function category_responsible_role_code(string $category, string $organizationUnit = 'F'): string
{
    $canonicalRoleCode = '';
    foreach (ticket_category_records(false) as $row) {
        if ((string)$row['name'] === $category) {
            $candidate = strtoupper((string)($row['responsible_role_code'] ?? ''));
            if (in_array($candidate, processing_role_codes(), true)) {
                $canonicalRoleCode = $candidate;
            }
            break;
        }
    }

    if ($canonicalRoleCode === '') {
        $canonicalRoleCode = match (ticket_category_code($category)) {
            '1' => 'ICTF',
            '2' => 'OFCF',
            '3' => 'VHCF',
            default => 'OTHF',
        };
    }

    return role_code_for_unit($canonicalRoleCode, $organizationUnit);
}


function first_name(string $name): string
{
    $name = trim($name);
    if ($name === '') {
        return 'korisniče';
    }
    $parts = preg_split('/\s+/', $name);
    return $parts[0] ?? $name;
}

function user_menu_badge_label(array $user): string
{
    if (is_system_admin($user)) {
        return 'Administracija sistema';
    }
    if (user_has_processing_role($user)) {
        return 'Podrška i obrada zahteva';
    }
    return 'Moj korisnički prostor';
}

function role_label(string $role): string
{
    $roleCode = strtoupper(trim($role));
    if ($roleCode === 'SYSTEM_ADMIN') {
        return 'Sistemski administrator';
    }
    return business_role_definitions()[$roleCode] ?? ucfirst($role);
}

function status_label(string $status): string
{
    return status_options()[$status] ?? ucfirst(str_replace('_', ' ', $status));
}

function priority_label(string $priority): string
{
    return priority_options()[$priority] ?? ucfirst($priority);
}

function status_class(string $status): string
{
    return 'status-' . preg_replace('/[^a-z_]/', '', $status);
}

function priority_class(string $priority): string
{
    return 'priority-' . preg_replace('/[^a-z]/', '', $priority);
}

function event_label(string $eventType): string
{
    return [
        'created' => 'Tiket kreiran',
        'comment' => 'Dodat komentar',
        'attachment_added' => 'Dodat prilog',
        'status_changed' => 'Promenjen status',
        'priority_changed' => 'Promenjen prioritet',
        'assigned_changed' => 'Promenjeno zaduženje',
        'participant_added' => 'Dodat učesnik',
        'category_changed' => 'Promenjena kategorija',
        'responsible_role_changed' => 'Promenjena odgovorna uloga',
        'responsible_role_set' => 'Odgovorna uloga automatski postavljena',
        'ticket_claimed' => 'Tiket preuzet',
    ][$eventType] ?? $eventType;
}

function format_event_value(?string $value, string $eventType = ''): string
{
    if ($value === null || $value === '') {
        return 'Nije dodeljeno';
    }
    if (in_array($eventType, ['assigned_changed', 'ticket_claimed'], true) && ctype_digit($value)) {
        try {
            $stmt = db()->prepare('SELECT name FROM users WHERE id = ? LIMIT 1');
            $stmt->execute([(int)$value]);
            $name = trim((string)$stmt->fetchColumn());
            return $name !== '' ? $name : 'Korisnik #' . $value;
        } catch (Throwable $e) {
            return 'Korisnik #' . $value;
        }
    }
    if ($eventType === 'status_changed' || in_array($value, array_keys(status_options()), true)) {
        return status_label($value);
    }
    if ($eventType === 'priority_changed' || in_array($value, array_keys(priority_options()), true)) {
        return priority_label($value);
    }
    if (in_array($eventType, ['responsible_role_changed', 'responsible_role_set'], true) || isset(business_role_definitions()[strtoupper($value)])) {
        // Audit baza čuva stabilan interni role_code; korisnički prikaz uvek koristi centralni display naziv.
        return role_label($value);
    }
    if ($value === 'internal') {
        return 'Interna beleška';
    }
    if ($value === 'public') {
        return 'Javni komentar';
    }
    return $value;
}

function my_tickets_access_sql(array $user): array
{
    return ['(t.user_id = ?
        OR t.assigned_to = ?
        OR EXISTS (
            SELECT 1
            FROM ticket_participants tp_mine
            WHERE tp_mine.ticket_id = t.id
              AND tp_mine.user_id = ?
        ))', [(int)$user['id'], (int)$user['id'], (int)$user['id']]];
}

function assigned_tickets_access_sql(array $user): array
{
    if (!is_system_admin($user) && !user_has_additional_role($user)) {
        return ['0=1', []];
    }

    if (is_system_admin($user) && !user_has_additional_role($user)) {
        return ['t.assigned_to = ?', [(int)$user['id']]];
    }

    $baseCodes = base_user_role_codes();
    $basePlaceholders = implode(',', array_fill(0, count($baseCodes), '?'));
    return ['(t.assigned_to = ?
        OR EXISTS (
            SELECT 1
            FROM user_roles ur_assigned
            JOIN roles r_assigned ON r_assigned.id = ur_assigned.role_id AND r_assigned.is_active = 1
            WHERE ur_assigned.user_id = ?
              AND r_assigned.role_code NOT IN (' . $basePlaceholders . ')
              AND r_assigned.role_code = t.responsible_role_code
        ))', array_merge([(int)$user['id'], (int)$user['id']], $baseCodes)];
}

function ticket_list_view(array $user, mixed $requestedView = null): string
{
    if (!is_null($requestedView) && !is_scalar($requestedView)) {
        http_response_code(403);
        exit('Nemate pravo pristupa traženom prikazu tiketa.');
    }
    $requestedView = strtolower(trim((string)$requestedView));
    if ($requestedView === '') {
        return is_system_admin($user) ? 'all' : 'mine';
    }

    if ($requestedView === 'mine') {
        return 'mine';
    }
    if ($requestedView === 'assigned') {
        if (!user_has_additional_role($user)) {
            http_response_code(403);
            exit('Nemate dodatnu ulogu potrebnu za pristup tiketima za koje ste zaduženi.');
        }
        return 'assigned';
    }
    if ($requestedView === 'all' && is_system_admin($user)) {
        return 'all';
    }

    http_response_code(403);
    exit('Nemate pravo pristupa traženom prikazu tiketa.');
}

function ticket_list_access_sql(array $user, string $view): array
{
    return match ($view) {
        'mine' => my_tickets_access_sql($user),
        'assigned' => assigned_tickets_access_sql($user),
        'all' => is_system_admin($user) ? ['1=1', []] : ['0=1', []],
        default => ['0=1', []],
    };
}

function ticket_list_label(string $view): string
{
    return match ($view) {
        'mine' => 'Moji tiketi',
        'assigned' => 'Tiketi za koje sam zadužen',
        'all' => 'Svi zahtevi',
        default => 'Tiketi',
    };
}

function ticket_access_sql(array $user): array
{
    if (is_system_admin($user)) {
        return ['1=1', []];
    }
    return ['(t.user_id = ?
        OR t.assigned_to = ?
        OR EXISTS (SELECT 1 FROM ticket_participants tp_access WHERE tp_access.ticket_id = t.id AND tp_access.user_id = ?)
        OR EXISTS (
            SELECT 1
            FROM user_roles ur_access
            JOIN roles r_access ON r_access.id = ur_access.role_id AND r_access.is_active = 1
            WHERE ur_access.user_id = ? AND r_access.role_code = t.responsible_role_code
        ))', [(int)$user['id'], (int)$user['id'], (int)$user['id'], (int)$user['id']]];
}

function can_view_ticket(array $ticket, array $user): bool
{
    if (is_system_admin($user)) {
        return true;
    }
    if ((int)$ticket['user_id'] === (int)$user['id']) {
        return true;
    }
    if ((int)($ticket['assigned_to'] ?? 0) === (int)$user['id']) {
        return true;
    }
    if (user_has_role($user, (string)($ticket['responsible_role_code'] ?? ''))) {
        return true;
    }
    $stmt = db()->prepare('SELECT 1 FROM ticket_participants WHERE ticket_id = ? AND user_id = ? LIMIT 1');
    $stmt->execute([(int)$ticket['id'], (int)$user['id']]);
    return (bool)$stmt->fetchColumn();
}

function can_manage_ticket(array $ticket, array $user): bool
{
    return is_system_admin($user)
        || (int)($ticket['assigned_to'] ?? 0) === (int)$user['id']
        || user_has_role($user, (string)($ticket['responsible_role_code'] ?? ''));
}

function can_manage_ticket_participants(array $ticket, array $user): bool
{
    return is_system_admin($user)
        || (int)($ticket['user_id'] ?? 0) === (int)$user['id']
        || (int)($ticket['assigned_to'] ?? 0) === (int)$user['id']
        || user_has_role($user, (string)($ticket['responsible_role_code'] ?? ''));
}

function eligible_assignees(string $roleCode): array
{
    $roleCode = strtoupper(trim($roleCode));
    if (!in_array($roleCode, processing_role_codes(), true)) {
        return [];
    }
    $stmt = db()->prepare('SELECT DISTINCT u.id, u.name, u.email
        FROM users u
        JOIN user_roles ur ON ur.user_id = u.id
        JOIN roles r ON r.id = ur.role_id
        WHERE u.is_active = 1 AND r.is_active = 1 AND r.role_code = ?
        ORDER BY u.name ASC, u.email ASC');
    $stmt->execute([$roleCode]);
    return $stmt->fetchAll();
}

function normalize_participant_ids($value, int $limit = 20): array
{
    $values = is_array($value) ? $value : [$value];
    $ids = [];
    foreach ($values as $item) {
        $id = filter_var($item, FILTER_VALIDATE_INT, ['options' => ['min_range' => 1]]);
        if ($id !== false) {
            $ids[(int)$id] = (int)$id;
        }
        if (count($ids) >= $limit) {
            break;
        }
    }
    return array_values($ids);
}

function participant_users_by_ids(array $ids): array
{
    $ids = normalize_participant_ids($ids);
    if (!$ids) {
        return [];
    }
    $placeholders = implode(',', array_fill(0, count($ids), '?'));
    $stmt = db()->prepare("SELECT id, name, email FROM users WHERE is_active = 1 AND id IN ($placeholders)");
    $stmt->execute($ids);
    $byId = [];
    foreach ($stmt->fetchAll() as $row) {
        $byId[(int)$row['id']] = $row;
    }
    $ordered = [];
    foreach ($ids as $id) {
        if (isset($byId[$id])) {
            $ordered[] = $byId[$id];
        }
    }
    return $ordered;
}

function ticket_participants(int $ticketId): array
{
    $stmt = db()->prepare('SELECT u.id, u.name, u.email, tp.created_at
        FROM ticket_participants tp
        JOIN users u ON u.id = tp.user_id
        WHERE tp.ticket_id = ? AND u.is_active = 1
        ORDER BY u.name ASC, u.email ASC');
    $stmt->execute([$ticketId]);
    return $stmt->fetchAll();
}

function add_ticket_participants(PDO $pdo, int $ticketId, int $addedBy, int $ticketOwnerId, array $ids): array
{
    $users = participant_users_by_ids($ids);
    if (!$users) {
        return [];
    }
    $insert = $pdo->prepare('INSERT IGNORE INTO ticket_participants (ticket_id, user_id, added_by, created_at) VALUES (?, ?, ?, NOW())');
    $added = [];
    foreach ($users as $participant) {
        $participantId = (int)$participant['id'];
        if ($participantId === $ticketOwnerId) {
            continue;
        }
        $insert->execute([$ticketId, $participantId, $addedBy]);
        if ($insert->rowCount() > 0) {
            $added[] = $participant;
        }
    }
    return $added;
}

function generate_ticket_no(PDO $pdo, string $category = 'Ostalo'): string
{
    $categoryCode = ticket_category_code($category);
    $datePart = (new DateTimeImmutable('now', app_timezone()))->format('Ymd');

    do {
        $ticketNo = 'SOS-' . $datePart . '-' . $categoryCode . str_pad((string)random_int(0, 9999), 4, '0', STR_PAD_LEFT);
        $stmt = $pdo->prepare('SELECT id FROM tickets WHERE ticket_no = ? LIMIT 1');
        $stmt->execute([$ticketNo]);
    } while ($stmt->fetch());

    return $ticketNo;
}

function audit_ticket_event(int $ticketId, int $userId, string $eventType, ?string $oldValue, ?string $newValue): void
{
    $stmt = db()->prepare('INSERT INTO ticket_events (ticket_id, user_id, event_type, old_value, new_value, created_at) VALUES (?, ?, ?, ?, ?, NOW())');
    $stmt->execute([$ticketId, $userId, $eventType, $oldValue, $newValue]);
}

function safe_selected($actual, $expected): string
{
    return (string)$actual === (string)$expected ? 'selected' : '';
}

function safe_checked(bool $condition): string
{
    return $condition ? 'checked' : '';
}

function app_timezone(): DateTimeZone
{
    return new DateTimeZone(defined('APP_TIMEZONE') ? APP_TIMEZONE : 'Europe/Belgrade');
}

function now_app_datetime_sql(): string
{
    return (new DateTimeImmutable('now', app_timezone()))->format('Y-m-d H:i:s');
}

function format_datetime(?string $value): string
{
    if (!$value) {
        return '—';
    }
    try {
        $tz = app_timezone();
        $dt = new DateTimeImmutable((string)$value, $tz);
        return $dt->setTimezone($tz)->format('d.m.Y. H:i');
    } catch (Throwable $e) {
        return '—';
    }
}

function initials(string $name): string
{
    $parts = preg_split('/\s+/u', trim($name));
    $letters = '';
    foreach ($parts as $part) {
        if ($part !== '') {
            $letters .= mb_substr($part, 0, 1);
        }
        if (mb_strlen($letters) >= 2) {
            break;
        }
    }
    return mb_strtoupper($letters ?: 'K');
}


function profile_photo_stored_path_is_valid(?string $storedPath): bool
{
    $storedPath = trim((string)$storedPath);
    return $storedPath !== ''
        && preg_match('#\Auploads/avatars/[a-zA-Z0-9][a-zA-Z0-9._-]{0,199}\.(?:jpg|jpeg|png)\z#Di', $storedPath) === 1;
}

function profile_photo_storage_path(?string $storedPath): ?string
{
    if (!profile_photo_stored_path_is_valid($storedPath)) {
        return null;
    }

    $avatarDirectory = realpath(dirname(__DIR__) . '/uploads/avatars');
    if ($avatarDirectory === false || !is_dir($avatarDirectory)) {
        return null;
    }

    $candidate = realpath(dirname(__DIR__) . '/' . ltrim((string)$storedPath, '/'));
    if ($candidate === false || !is_file($candidate)) {
        return null;
    }

    $directoryPrefix = rtrim($avatarDirectory, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR;
    if (!str_starts_with($candidate, $directoryPrefix)) {
        return null;
    }

    return $candidate;
}

/**
 * Vraća proverene metapodatke avatara ili null.
 * Ekstenzija, stvarni image type i MIME moraju međusobno da se podudaraju.
 */
function profile_photo_file_info(?string $storedPath): ?array
{
    static $cache = [];
    $cacheKey = (string)$storedPath;
    if (array_key_exists($cacheKey, $cache)) {
        return $cache[$cacheKey];
    }

    $absolutePath = profile_photo_storage_path($storedPath);
    if ($absolutePath === null) {
        return $cache[$cacheKey] = null;
    }

    $extension = strtolower((string)pathinfo($absolutePath, PATHINFO_EXTENSION));
    $allowed = [
        'jpg' => ['mime' => 'image/jpeg', 'type' => IMAGETYPE_JPEG],
        'jpeg' => ['mime' => 'image/jpeg', 'type' => IMAGETYPE_JPEG],
        'png' => ['mime' => 'image/png', 'type' => IMAGETYPE_PNG],
    ];
    if (!isset($allowed[$extension])) {
        return $cache[$cacheKey] = null;
    }

    $imageInfo = @getimagesize($absolutePath);
    $imageType = is_array($imageInfo) ? (int)($imageInfo[2] ?? 0) : 0;
    if ($imageType !== $allowed[$extension]['type']) {
        return $cache[$cacheKey] = null;
    }

    $detectedMime = null;
    if (class_exists('finfo')) {
        try {
            $finfo = new finfo(FILEINFO_MIME_TYPE);
            $detectedMime = $finfo->file($absolutePath) ?: null;
        } catch (Throwable $e) {
            $detectedMime = null;
        }
    }
    if ($detectedMime !== null && $detectedMime !== $allowed[$extension]['mime']) {
        return $cache[$cacheKey] = null;
    }

    $fileSize = @filesize($absolutePath);
    if (!is_int($fileSize) || $fileSize <= 0) {
        return $cache[$cacheKey] = null;
    }

    return $cache[$cacheKey] = [
        'path' => $absolutePath,
        'mime' => $allowed[$extension]['mime'],
        'size' => $fileSize,
    ];
}

function can_view_user_avatar(array $viewer, int $targetUserId): bool
{
    $viewerId = (int)($viewer['id'] ?? 0);
    if ($viewerId <= 0 || $targetUserId <= 0) {
        return false;
    }

    return $viewerId === $targetUserId || is_system_admin($viewer);
}

function user_profile_photo_url(array $user): ?string
{
    $viewer = current_user();
    $renderedUserId = (int)($user['id'] ?? 0);
    $photo = trim((string)($user['profile_photo'] ?? ''));

    // SEC-AVATAR-RBAC-ACCESS-CONTROL: običan korisnik vidi samo svoj avatar,
    // dok sistemski administrator može da vidi avatare drugih korisnika.
    if ($viewer === null || !can_view_user_avatar($viewer, $renderedUserId)) {
        return null;
    }
    if (profile_photo_file_info($photo) === null) {
        return null;
    }

    // Za sopstveni avatar nije potreban identifikator u URL-u.
    if ((int)$viewer['id'] === $renderedUserId) {
        return url('serve_avatar.php');
    }

    // user_id se generiše samo za administratorski prikaz; endpoint ponovo
    // proverava administratorsko pravo iz baze i ne veruje samom URL-u.
    return url('serve_avatar.php?user_id=' . $renderedUserId);
}

function user_avatar_html(array $user, string $class = 'avatar'): string
{
    $photoUrl = user_profile_photo_url($user);
    $name = trim((string)($user['name'] ?? 'Korisnik'));
    $classes = trim($class . ($photoUrl ? ' has-photo' : ''));

    if ($photoUrl) {
        return '<span class="' . e($classes) . '"><img src="' . e($photoUrl) . '" alt="Profilna fotografija korisnika ' . e($name) . '" loading="lazy"></span>';
    }

    return '<span class="' . e($classes) . '">' . e(initials($name)) . '</span>';
}

function profile_photo_max_size_bytes(): int
{
    return 2 * 1024 * 1024;
}

function profile_photo_allowed_mime_types(): array
{
    return [
        'image/jpeg' => 'jpg',
        'image/pjpeg' => 'jpg',
        'image/png' => 'png',
    ];
}

function ticket_attachment_max_files(): int
{
    return defined('TICKET_ATTACHMENT_MAX_FILES') ? max(1, (int)TICKET_ATTACHMENT_MAX_FILES) : 10;
}

function ticket_attachment_max_total_bytes(): int
{
    return defined('TICKET_ATTACHMENT_MAX_TOTAL_BYTES') ? max(1, (int)TICKET_ATTACHMENT_MAX_TOTAL_BYTES) : 50 * 1024 * 1024;
}

function ticket_attachment_allowed_types(): array
{
    return [
        'jpg' => ['image/jpeg', 'image/pjpeg'],
        'jpeg' => ['image/jpeg', 'image/pjpeg'],
        'png' => ['image/png'],
        'pdf' => ['application/pdf'],
        'xls' => ['application/vnd.ms-excel', 'application/x-ole-storage', 'application/CDFV2', 'application/octet-stream'],
        'xlsx' => ['application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', 'application/zip', 'application/octet-stream'],
        'txt' => ['text/plain'],
    ];
}

function ticket_attachment_type_is_allowed(string $extension, string $mime, string $path): bool
{
    $allowed = ticket_attachment_allowed_types();
    if (!isset($allowed[$extension]) || !in_array($mime, $allowed[$extension], true)) {
        return false;
    }

    if ($extension === 'xls') {
        $handle = fopen($path, 'rb');
        $signature = $handle ? fread($handle, 8) : false;
        if ($handle) {
            fclose($handle);
        }
        return $signature === "\xD0\xCF\x11\xE0\xA1\xB1\x1A\xE1";
    }

    if ($extension === 'xlsx') {
        if (!class_exists('ZipArchive')) {
            return false;
        }
        $zip = new ZipArchive();
        if ($zip->open($path) !== true) {
            return false;
        }
        $valid = $zip->locateName('[Content_Types].xml') !== false && $zip->locateName('xl/workbook.xml') !== false;
        $zip->close();
        return $valid;
    }

    return true;
}

function normalized_uploaded_files(string $field): array
{
    if (!isset($_FILES[$field]) || !is_array($_FILES[$field]['name'] ?? null)) {
        return [];
    }

    $files = [];
    foreach ($_FILES[$field]['name'] as $index => $name) {
        $error = (int)($_FILES[$field]['error'][$index] ?? UPLOAD_ERR_NO_FILE);
        if ($error === UPLOAD_ERR_NO_FILE) {
            continue;
        }
        $files[] = [
            'name' => (string)$name,
            'tmp_name' => (string)($_FILES[$field]['tmp_name'][$index] ?? ''),
            'error' => $error,
            'size' => (int)($_FILES[$field]['size'][$index] ?? 0),
        ];
    }
    return $files;
}

function validate_ticket_attachments(array $files): array
{
    $errors = [];
    if (count($files) > ticket_attachment_max_files()) {
        return ['Možete dodati najviše ' . ticket_attachment_max_files() . ' fajlova.'];
    }

    $total = 0;
    $finfo = new finfo(FILEINFO_MIME_TYPE);
    foreach ($files as $file) {
        $displayName = basename(str_replace('\\', '/', (string)$file['name']));
        if ((int)$file['error'] !== UPLOAD_ERR_OK) {
            $errors[] = 'Upload fajla „' . $displayName . '“ nije uspeo (kod ' . (int)$file['error'] . ').';
            continue;
        }
        if (!is_uploaded_file((string)$file['tmp_name'])) {
            $errors[] = 'Fajl „' . $displayName . '“ nije validan upload.';
            continue;
        }
        if ((int)$file['size'] <= 0) {
            $errors[] = 'Fajl „' . $displayName . '“ je prazan.';
            continue;
        }
        $total += (int)$file['size'];
        $extension = strtolower(pathinfo($displayName, PATHINFO_EXTENSION));
        $mime = (string)$finfo->file((string)$file['tmp_name']);
        if (!ticket_attachment_type_is_allowed($extension, $mime, (string)$file['tmp_name'])) {
            $errors[] = 'Fajl „' . $displayName . '“ nema dozvoljen format ili sadržaj.';
        }
    }

    if ($total > ticket_attachment_max_total_bytes()) {
        $errors[] = 'Ukupna veličina priloga ne sme biti veća od 50 MB.';
    }
    return $errors;
}

function store_ticket_attachments(PDO $pdo, int $ticketId, int $userId, array $files, int $isInternal = 0): array
{
    if (!$files) {
        return [];
    }

    $directory = dirname(__DIR__) . '/uploads/tickets';
    if (!is_dir($directory) && !mkdir($directory, 0750, true) && !is_dir($directory)) {
        throw new RuntimeException('Folder za priloge nije moguće kreirati.');
    }

    $savedPaths = [];
    $finfo = new finfo(FILEINFO_MIME_TYPE);
    $stmt = $pdo->prepare('INSERT INTO ticket_attachments (ticket_id, user_id, is_internal, original_name, stored_name, mime_type, file_size, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, NOW())');
    try {
        foreach ($files as $file) {
            $originalName = mb_substr(basename(str_replace('\\', '/', (string)$file['name'])), 0, 255);
            $extension = strtolower(pathinfo($originalName, PATHINFO_EXTENSION));
            $storedName = 'ticket-' . $ticketId . '-' . bin2hex(random_bytes(16)) . '.' . $extension;
            $destination = $directory . '/' . $storedName;
            if (!move_uploaded_file((string)$file['tmp_name'], $destination)) {
                throw new RuntimeException('Prilog nije moguće sačuvati.');
            }
            chmod($destination, 0640);
            $savedPaths[] = $destination;
            $stmt->execute([$ticketId, $userId, $isInternal === 1 ? 1 : 0, $originalName, $storedName, (string)$finfo->file($destination), (int)$file['size']]);
        }
        return $savedPaths;
    } catch (Throwable $e) {
        foreach ($savedPaths as $path) {
            if (is_file($path)) {
                unlink($path);
            }
        }
        throw $e;
    }
}

function format_file_size(int $bytes): string
{
    if ($bytes >= 1024 * 1024) {
        return number_format($bytes / (1024 * 1024), 1, ',', '.') . ' MB';
    }
    return number_format(max(1, $bytes / 1024), 0, ',', '.') . ' KB';
}

function is_active_page(array $pages): string
{
    $current = basename($_SERVER['SCRIPT_NAME'] ?? '');
    return in_array($current, $pages, true) ? 'aktivan' : '';
}

function limit_text(string $text, int $length = 120): string
{
    $text = trim(preg_replace('/\s+/u', ' ', $text));
    if (mb_strlen($text) <= $length) {
        return $text;
    }
    return mb_substr($text, 0, $length - 1) . '…';
}


function employee_value(?string $value): string
{
    $value = trim((string)$value);
    return $value !== '' ? $value : 'Nije uneto';
}

function equipment_types(): array
{
    return [
        'Laptop' => 'Laptop',
        'Telefon' => 'Telefon',
        'Monitor' => 'Monitor',
        'Docking station' => 'Docking station',
        'Tablet' => 'Tablet',
        'Ostalo' => 'Ostalo',
    ];
}

function equipment_statuses(): array
{
    return [
        'assigned' => 'Dodeljeno',
        'returned' => 'Vraćeno',
        'repair' => 'Na servisu',
        'retired' => 'Rashodovano',
    ];
}

function equipment_status_label(string $status): string
{
    return equipment_statuses()[$status] ?? $status;
}

function equipment_status_class(string $status): string
{
    return 'equipment-' . preg_replace('/[^a-z]/', '', $status);
}

function equipment_filter_types(): array
{
    return [
        'Laptop' => 'Laptop',
        'Telefon' => 'Telefon',
        'Monitor' => 'Monitor',
        'Tablet' => 'Tablet',
        'Ostalo' => 'Ostalo',
    ];
}

function normalize_equipment_filters(array $input): array
{
    $assetType = trim((string)($input['asset_type'] ?? ''));
    if ($assetType !== '' && !array_key_exists($assetType, equipment_filter_types())) {
        // Backward compatibility for old URLs using a detailed non-primary type.
        $assetType = array_key_exists($assetType, equipment_types()) ? 'Ostalo' : '';
    }

    $status = trim((string)($input['status'] ?? ''));
    if ($status !== '' && !array_key_exists($status, equipment_statuses())) {
        $status = '';
    }

    $warehouse = trim((string)($input['warehouse'] ?? ''));
    if (!in_array($warehouse, ['', '1', '0'], true)) {
        $warehouse = '';
    }

    $organizationUnit = strtoupper(trim((string)($input['organization_unit'] ?? '')));
    if ($organizationUnit !== '' && !isset(organization_unit_definitions()[$organizationUnit])) {
        $organizationUnit = '';
    }

    return [
        'q' => mb_substr(trim((string)($input['q'] ?? '')), 0, 190),
        'user_id' => max(0, (int)($input['user_id'] ?? 0)),
        'asset_type' => $assetType,
        'status' => $status,
        'warehouse' => $warehouse,
        'organization_unit' => $organizationUnit,
    ];
}

function equipment_filter_sql(array $filters, ?array $user = null): array
{
    $where = ['1=1'];
    $params = [];

    if ($user !== null) {
        [$scopeSql, $scopeParams] = equipment_access_scope_sql($user, 'e');
        $where[] = $scopeSql;
        array_push($params, ...$scopeParams);
    }

    $organizationUnit = strtoupper(trim((string)($filters['organization_unit'] ?? '')));
    if ($organizationUnit !== '' && isset(organization_unit_definitions()[$organizationUnit])) {
        if ($user === null || is_system_admin($user)) {
            $where[] = 'e.organization_unit = ?';
            $params[] = $organizationUnit;
        }
    }

    $userId = (int)($filters['user_id'] ?? 0);
    if ($userId > 0) {
        $where[] = 'e.user_id = ?';
        $params[] = $userId;
    }

    $status = (string)($filters['status'] ?? '');
    if ($status !== '' && array_key_exists($status, equipment_statuses())) {
        $where[] = 'e.status = ?';
        $params[] = $status;
    }

    $assetType = (string)($filters['asset_type'] ?? '');
    if ($assetType === 'Ostalo') {
        $where[] = "e.asset_type NOT IN ('Laptop', 'Telefon', 'Monitor', 'Tablet')";
    } elseif ($assetType !== '' && array_key_exists($assetType, equipment_filter_types())) {
        $where[] = 'e.asset_type = ?';
        $params[] = $assetType;
    }

    $warehouse = (string)($filters['warehouse'] ?? '');
    if ($warehouse === '1') {
        $where[] = 'e.is_in_warehouse = 1';
    } elseif ($warehouse === '0') {
        $where[] = 'e.is_in_warehouse = 0';
    }

    $q = (string)($filters['q'] ?? '');
    if ($q !== '') {
        $where[] = '(LOCATE(?, e.asset_name) > 0 OR LOCATE(?, e.serial_no) > 0 OR LOCATE(?, e.inventory_no) > 0 OR LOCATE(?, e.notes) > 0 OR LOCATE(?, u.name) > 0 OR LOCATE(?, u.email) > 0 OR LOCATE(?, u.department) > 0 OR LOCATE(?, u.location) > 0)';
        array_push($params, $q, $q, $q, $q, $q, $q, $q, $q);
    }

    return [implode(' AND ', $where), $params];
}

function user_with_organization_unit(int $userId): ?array
{
    if ($userId <= 0) {
        return null;
    }
    $stmt = db()->prepare('SELECT u.id, u.name, u.email,
        COALESCE(GROUP_CONCAT(r.role_code ORDER BY r.sort_order SEPARATOR ","), "") AS role_codes_csv
        FROM users u
        LEFT JOIN user_roles ur ON ur.user_id = u.id
        LEFT JOIN roles r ON r.id = ur.role_id AND r.is_active = 1
        WHERE u.id = ? AND u.is_active = 1
        GROUP BY u.id, u.name, u.email
        LIMIT 1');
    $stmt->execute([$userId]);
    $row = $stmt->fetch();
    if (!$row) {
        return null;
    }
    $row['role_codes'] = array_values(array_filter(explode(',', (string)$row['role_codes_csv'])));
    $row['organization_unit'] = user_organization_unit($row);
    return $row;
}

function equipment_inventory_number_exists(string $organizationUnit, string $inventoryNo, int $excludeEquipmentId = 0): bool
{
    $organizationUnit = strtoupper(trim($organizationUnit));
    $inventoryNo = trim($inventoryNo);
    if ($inventoryNo === '' || !isset(organization_unit_definitions()[$organizationUnit])) {
        return false;
    }
    $sql = 'SELECT id FROM equipment WHERE organization_unit = ? AND inventory_no = ?';
    $params = [$organizationUnit, $inventoryNo];
    if ($excludeEquipmentId > 0) {
        $sql .= ' AND id <> ?';
        $params[] = $excludeEquipmentId;
    }
    $stmt = db()->prepare($sql . ' LIMIT 1');
    $stmt->execute($params);
    return $stmt->fetchColumn() !== false;
}

function record_equipment_audit_event(int $equipmentId, int $actorUserId, string $action, $oldValue = null, $newValue = null): void
{
    $encode = static function ($value): ?string {
        if ($value === null) {
            return null;
        }
        if (is_string($value)) {
            return $value;
        }
        return json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR);
    };
    $stmt = db()->prepare('INSERT INTO equipment_audit_events
        (equipment_id, actor_user_id, action, old_value, new_value, created_at)
        VALUES (?, ?, ?, ?, ?, NOW())');
    $stmt->execute([
        $equipmentId > 0 ? $equipmentId : null,
        $actorUserId > 0 ? $actorUserId : null,
        mb_substr(trim($action), 0, 64),
        $encode($oldValue),
        $encode($newValue),
    ]);
}

function equipment_summary_type(string $assetType): string
{
    return in_array($assetType, ['Laptop', 'Telefon', 'Monitor', 'Tablet'], true) ? $assetType : 'Ostalo';
}


function record_equipment_assignment_event(
    int $equipmentId,
    ?int $oldUserId,
    ?string $oldUserName,
    ?int $newUserId,
    ?string $newUserName,
    bool $oldWarehouse,
    bool $newWarehouse,
    int $changedBy
): void {
    if ($equipmentId <= 0) {
        return;
    }
    $stmt = db()->prepare('INSERT INTO equipment_assignment_events
        (equipment_id, old_user_id, old_user_name, new_user_id, new_user_name, old_is_in_warehouse, new_is_in_warehouse, changed_by, created_at)
        VALUES (?, ?, ?, ?, ?, ?, ?, ?, NOW())');
    $stmt->execute([
        $equipmentId,
        $oldUserId,
        $oldUserName !== null && trim($oldUserName) !== '' ? trim($oldUserName) : null,
        $newUserId,
        $newUserName !== null && trim($newUserName) !== '' ? trim($newUserName) : null,
        $oldWarehouse ? 1 : 0,
        $newWarehouse ? 1 : 0,
        $changedBy > 0 ? $changedBy : null,
    ]);
}

function pagination_page_numbers(int $currentPage, int $totalPages, int $radius = 2): array
{
    if ($totalPages <= 1) {
        return [];
    }
    $currentPage = max(1, min($currentPage, $totalPages));
    $wanted = [1, $totalPages];
    for ($page = max(1, $currentPage - $radius); $page <= min($totalPages, $currentPage + $radius); $page++) {
        $wanted[] = $page;
    }
    $wanted = array_values(array_unique($wanted));
    sort($wanted, SORT_NUMERIC);

    $result = [];
    $previous = 0;
    foreach ($wanted as $page) {
        if ($previous > 0 && $page > $previous + 1) {
            $result[] = null;
        }
        $result[] = $page;
        $previous = $page;
    }
    return $result;
}

function page_url(string $path, array $params): string
{
    $filtered = [];
    foreach ($params as $key => $value) {
        if ($value === null || $value === '' || $value === false) {
            continue;
        }
        $filtered[$key] = $value;
    }
    $query = http_build_query($filtered);
    return url($path . ($query !== '' ? '?' . $query : ''));
}


function sos_icon_svg(string $name): string
{
    $icons = [
        'ticket-open' => '<svg viewBox="0 0 24 24" width="21" height="21" aria-hidden="true" focusable="false"><path d="M4 7.5A2.5 2.5 0 0 1 6.5 5h11A2.5 2.5 0 0 1 20 7.5v2.2a2.3 2.3 0 0 0 0 4.6v2.2A2.5 2.5 0 0 1 17.5 19h-11A2.5 2.5 0 0 1 4 16.5v-2.2a2.3 2.3 0 0 0 0-4.6V7.5Zm5 1.3v6.4h6V8.8H9Zm1.5 1.5h3v3.4h-3v-3.4Z" fill="currentColor"/></svg>',
        'progress' => '<svg viewBox="0 0 24 24" width="21" height="21" aria-hidden="true" focusable="false"><path d="M12 3a9 9 0 0 1 8.9 7.8h-2.1A7 7 0 1 0 12 19a6.9 6.9 0 0 0 5-2.1l-2.4-2.4H21v6.4l-2.5-2.5A9 9 0 1 1 12 3Zm1 4v5.2l4 2.4-1 1.6-5-3V7h2Z" fill="currentColor"/></svg>',
        'waiting' => '<svg viewBox="0 0 24 24" width="21" height="21" aria-hidden="true" focusable="false"><path d="M7 3h10v6.1c0 1-.4 1.9-1.1 2.6L13.6 14l2.3 2.3c.7.7 1.1 1.6 1.1 2.6V21H7v-2.1c0-1 .4-1.9 1.1-2.6l2.3-2.3-2.3-2.3A3.7 3.7 0 0 1 7 9.1V3Zm2 2v4.1c0 .5.2.9.5 1.2L12 12.8l2.5-2.5c.3-.3.5-.8.5-1.2V5H9Zm3 10.2-2.5 2.5c-.3.3-.5.8-.5 1.2v.1h6v-.1c0-.5-.2-.9-.5-1.2L12 15.2Z" fill="currentColor"/></svg>',
        'check' => '<svg viewBox="0 0 24 24" width="21" height="21" aria-hidden="true" focusable="false"><path d="M12 2.8a9.2 9.2 0 1 1 0 18.4 9.2 9.2 0 0 1 0-18.4Zm4.6 6.3-5.7 5.7-2.5-2.5L7 13.8l3.9 3.9 7.1-7.1-1.4-1.5Z" fill="currentColor"/></svg>',
        'laptop' => '<svg viewBox="0 0 24 24" width="22" height="22" aria-hidden="true" focusable="false"><path d="M5 5h14a1.5 1.5 0 0 1 1.5 1.5V16h-17V6.5A1.5 1.5 0 0 1 5 5Zm.5 2v7h13V7h-13ZM2.5 17h19l-1.1 2H3.6l-1.1-2Z" fill="currentColor"/></svg>',
        'phone' => '<svg viewBox="0 0 24 24" width="22" height="22" aria-hidden="true" focusable="false"><path d="M8 2.8h8A2.2 2.2 0 0 1 18.2 5v14A2.2 2.2 0 0 1 16 21.2H8A2.2 2.2 0 0 1 5.8 19V5A2.2 2.2 0 0 1 8 2.8Zm0 2V17h8V4.8H8Zm3 13.2v1.2h2V18h-2Z" fill="currentColor"/></svg>',
        'tablet' => '<svg viewBox="0 0 24 24" width="22" height="22" aria-hidden="true" focusable="false"><path d="M7 2.8h10A2.2 2.2 0 0 1 19.2 5v14A2.2 2.2 0 0 1 17 21.2H7A2.2 2.2 0 0 1 4.8 19V5A2.2 2.2 0 0 1 7 2.8Zm0 2V17h10V4.8H7Zm4 13.2v1.1h2V18h-2Z" fill="currentColor"/></svg>',
        'monitor' => '<svg viewBox="0 0 24 24" width="22" height="22" aria-hidden="true" focusable="false"><path d="M4.5 4h15A1.5 1.5 0 0 1 21 5.5v10A1.5 1.5 0 0 1 19.5 17H13v2h3v2H8v-2h3v-2H4.5A1.5 1.5 0 0 1 3 15.5v-10A1.5 1.5 0 0 1 4.5 4ZM5 6v9h14V6H5Z" fill="currentColor"/></svg>',
        'dock' => '<svg viewBox="0 0 24 24" width="22" height="22" aria-hidden="true" focusable="false"><path d="M6 8h12a3 3 0 0 1 3 3v5a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-5a3 3 0 0 1 3-3Zm0 2a1 1 0 0 0-1 1v5h14v-5a1 1 0 0 0-1-1H6Zm2 2h2v2H8v-2Zm3 0h2v2h-2v-2Zm3 0h2v2h-2v-2ZM9 5h6v2H9V5Z" fill="currentColor"/></svg>',
        'asset' => '<svg viewBox="0 0 24 24" width="22" height="22" aria-hidden="true" focusable="false"><path d="m12 2.8 8 4.2v10l-8 4.2-8-4.2V7l8-4.2Zm0 2.2L6.8 7.7 12 10.4l5.2-2.7L12 5ZM6 9.4v6.4l5 2.6V12L6 9.4Zm7 9 5-2.6V9.4L13 12v6.4Z" fill="currentColor"/></svg>',
    ];
    return $icons[$name] ?? $icons['asset'];
}

function status_metric_icon_html(string $status): string
{
    return match ($status) {
        'open' => sos_icon_svg('ticket-open'),
        'in_progress' => sos_icon_svg('progress'),
        'waiting' => sos_icon_svg('waiting'),
        'resolved' => sos_icon_svg('check'),
        default => sos_icon_svg('ticket-open'),
    };
}

function equipment_icon_html(string $assetType): string
{
    $normalized = mb_strtolower(trim($assetType));
    $name = match ($normalized) {
        'laptop' => 'laptop',
        'telefon' => 'phone',
        'tablet' => 'tablet',
        'monitor' => 'monitor',
        'docking station', 'dock', 'doking station' => 'dock',
        default => 'asset',
    };
    return sos_icon_svg($name);
}

function notify_user(int $userId, string $title, string $message, string $linkUrl = ''): void
{
    if ($userId <= 0) {
        return;
    }
    try {
        $stmt = db()->prepare('INSERT INTO notifications (user_id, title, message, link_url, is_read, created_at) VALUES (?, ?, ?, ?, 0, NOW())');
        $stmt->execute([$userId, mb_substr($title, 0, 160), mb_substr($message, 0, 500), mb_substr(safe_internal_path($linkUrl, ''), 0, 255)]);
    } catch (Throwable $e) {
        // Notifikacije ne smeju da blokiraju osnovni rad sistema ako migracija nije importovana.
    }
}

function notify_responsible_role(string $roleCode, string $title, string $message, string $linkUrl = '', array $excludeUserIds = []): void
{
    $roleCode = strtoupper(trim($roleCode));
    if (!in_array($roleCode, processing_role_codes(), true)) {
        return;
    }
    try {
        $sql = 'INSERT INTO notifications (user_id, title, message, link_url, is_read, created_at)
            SELECT DISTINCT u.id, ?, ?, ?, 0, NOW()
            FROM users u
            JOIN user_roles ur ON ur.user_id = u.id
            JOIN roles r ON r.id = ur.role_id AND r.is_active = 1
            WHERE u.is_active = 1 AND r.role_code = ?';
        $params = [
            mb_substr($title, 0, 160),
            mb_substr($message, 0, 500),
            mb_substr(safe_internal_path($linkUrl, ''), 0, 255),
            $roleCode,
        ];
        $excluded = normalize_participant_ids($excludeUserIds, 50);
        if ($excluded) {
            $sql .= ' AND u.id NOT IN (' . implode(',', array_fill(0, count($excluded), '?')) . ')';
            array_push($params, ...$excluded);
        }
        $stmt = db()->prepare($sql);
        $stmt->execute($params);
    } catch (Throwable $e) {
    }
}

function notify_ticket_participants(int $ticketId, string $title, string $message, string $linkUrl = '', array $excludeUserIds = []): void
{
    try {
        $sql = 'INSERT INTO notifications (user_id, title, message, link_url, is_read, created_at)
            SELECT tp.user_id, ?, ?, ?, 0, NOW()
            FROM ticket_participants tp
            JOIN users u ON u.id = tp.user_id AND u.is_active = 1
            WHERE tp.ticket_id = ?';
        $params = [
            mb_substr($title, 0, 160),
            mb_substr($message, 0, 500),
            mb_substr(safe_internal_path($linkUrl, ''), 0, 255),
            $ticketId,
        ];
        $excluded = normalize_participant_ids($excludeUserIds, 50);
        if ($excluded) {
            $sql .= ' AND tp.user_id NOT IN (' . implode(',', array_fill(0, count($excluded), '?')) . ')';
            array_push($params, ...$excluded);
        }
        $stmt = db()->prepare($sql);
        $stmt->execute($params);
    } catch (Throwable $e) {
    }
}

function unread_notification_count(int $userId): int
{
    try {
        $stmt = db()->prepare('SELECT COUNT(*) FROM notifications WHERE user_id = ? AND is_read = 0');
        $stmt->execute([$userId]);
        return (int)$stmt->fetchColumn();
    } catch (Throwable $e) {
        return 0;
    }
}

function recent_notifications(int $userId, int $limit = 5): array
{
    try {
        $stmt = db()->prepare('SELECT id, title, message, link_url, is_read, created_at FROM notifications WHERE user_id = ? ORDER BY is_read ASC, created_at DESC LIMIT ' . max(1, min(20, $limit)));
        $stmt->execute([$userId]);
        return $stmt->fetchAll();
    } catch (Throwable $e) {
        return [];
    }
}

function mark_notification_read(int $notificationId, int $userId): ?string
{
    try {
        $stmt = db()->prepare('SELECT link_url FROM notifications WHERE id = ? AND user_id = ? LIMIT 1');
        $stmt->execute([$notificationId, $userId]);
        $row = $stmt->fetch();
        if (!$row) {
            return null;
        }
        $update = db()->prepare('UPDATE notifications SET is_read = 1, read_at = COALESCE(read_at, NOW()) WHERE id = ? AND user_id = ?');
        $update->execute([$notificationId, $userId]);
        return (string)($row['link_url'] ?? '');
    } catch (Throwable $e) {
        return null;
    }
}

function mark_all_notifications_read(int $userId): void
{
    try {
        $stmt = db()->prepare('UPDATE notifications SET is_read = 1, read_at = COALESCE(read_at, NOW()) WHERE user_id = ? AND is_read = 0');
        $stmt->execute([$userId]);
    } catch (Throwable $e) {
    }
}
