<?php
declare(strict_types=1);

class Helper
{
    public static function redirect(string $path, array $flash = []): never
    {
        foreach ($flash as $k => $v) {
            $_SESSION['flash'][$k] = $v;
        }
        $base = defined('APP_BASE') ? APP_BASE : '';
        header('Location: ' . $base . '/' . ltrim($path, '/'));
        exit;
    }

    public static function flash(string $key): ?string
    {
        $val = $_SESSION['flash'][$key] ?? null;
        unset($_SESSION['flash'][$key]);
        return $val;
    }

    public static function e(mixed $val): string
    {
        return htmlspecialchars((string)$val, ENT_QUOTES, 'UTF-8');
    }

    /**
     * Validate a user-supplied hex color. Returns the value unchanged when it
     * matches `#RRGGBB` (case-insensitive), otherwise the fallback. HTML
     * escaping protects HTML-syntax-injection but says nothing about CSS, and
     * unvalidated colors end up in `style="background:…"` attributes where an
     * attacker could write e.g. `red;background:url(https://x/log)` to track
     * viewers. Validating at write time keeps the DB clean and avoids the
     * issue at every render site.
     */
    public static function color(string $val, string $fallback = '#6b7280'): string
    {
        return preg_match('/^#[0-9a-fA-F]{6}$/', $val) ? $val : $fallback;
    }

    public static function csrf(): string
    {
        $token = Auth::generateCsrfToken();
        return '<input type="hidden" name="csrf_token" value="' . self::e($token) . '">';
    }

    public static function verifyCsrf(): void
    {
        $token = $_POST['csrf_token'] ?? '';
        if (!Auth::verifyCsrfToken($token)) {
            http_response_code(403);
            die('CSRF token mismatch.');
        }
    }

    public static function isPost(): bool
    {
        return $_SERVER['REQUEST_METHOD'] === 'POST';
    }

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

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

    /**
     * Sanitize user-supplied HTML.
     *
     * Backed by symfony/html-sanitizer (BSI APP.3.1.A17). The configured
     * allowlist matches the previous DOMDocument-based behaviour (same tags +
     * attributes), but the parser is the production-hardened Symfony engine —
     * mutation-XSS-resistant, CSP3-aware, and actively maintained.
     *
     * Same input/output contract as before: takes raw HTML, returns sanitized
     * HTML, returns empty string for empty/whitespace-only input.
     */
    public static function sanitizeHtml(string $html): string
    {
        if (trim(strip_tags($html)) === '') return '';

        static $sanitizer = null;
        if ($sanitizer === null) {
            $config = (new \Symfony\Component\HtmlSanitizer\HtmlSanitizerConfig())
                // Block-level
                ->allowElement('p')
                ->allowElement('br')
                ->allowElement('blockquote')
                ->allowElement('pre')
                ->allowElement('h2')
                ->allowElement('h3')
                // Inline emphasis
                ->allowElement('strong')
                ->allowElement('b')
                ->allowElement('em')
                ->allowElement('i')
                ->allowElement('u')
                ->allowElement('s')
                ->allowElement('code')
                // Lists
                ->allowElement('ul')
                ->allowElement('ol')
                ->allowElement('li')
                // Tables
                ->allowElement('table')
                ->allowElement('thead')
                ->allowElement('tbody')
                ->allowElement('tr')
                ->allowElement('td', ['colspan', 'rowspan'])
                ->allowElement('th', ['colspan', 'rowspan'])
                // Mentions / annotations
                ->allowElement('span', ['class', 'data-mention'])
                // Anchors: HTTP/HTTPS only; the sanitizer auto-forces rel=noopener for target=_blank
                ->allowElement('a', ['href', 'target', 'rel'])
                ->allowLinkSchemes(['http', 'https', 'mailto'])
                // Images: HTTP(S) only. data: URIs are intentionally NOT allowed —
                // distinguishing safe raster MIME types from data:image/svg+xml at
                // sanitizer level is brittle. If a user truly needs to embed an
                // image, they should upload it via the attachment pipeline instead.
                ->allowElement('img', ['src', 'alt', 'width', 'height'])
                ->allowMediaSchemes(['http', 'https'])
                ->allowRelativeLinks()
                ->allowRelativeMedias()
                ->forceHttpsUrls(false)
                ->withMaxInputLength(1_000_000);
            $sanitizer = new \Symfony\Component\HtmlSanitizer\HtmlSanitizer($config);
        }

        return $sanitizer->sanitize($html);
    }

    public static function formatDate(?string $date, ?string $timezone = null): string
    {
        if (empty($date)) return '—';
        try {
            $dt = new \DateTimeImmutable($date, new \DateTimeZone('UTC'));
            return $dt->setTimezone(new \DateTimeZone($timezone ?? self::currentTimezone()))->format('d.m.Y');
        } catch (\Throwable) {
            return date('d.m.Y', strtotime($date));
        }
    }

    public static function formatDateTime(?string $date, ?string $timezone = null): string
    {
        if (empty($date)) return '—';
        try {
            $dt = new \DateTimeImmutable($date, new \DateTimeZone('UTC'));
            return $dt->setTimezone(new \DateTimeZone($timezone ?? self::currentTimezone()))->format('d.m.Y H:i');
        } catch (\Throwable) {
            return date('d.m.Y H:i', strtotime($date));
        }
    }

    public static function timeAgo(string $date): string
    {
        $diff = time() - strtotime($date);
        if ($diff < 60) return Lang::get('just_now');
        if ($diff < 3600) return floor($diff / 60) . ' ' . Lang::get('minutes_ago');
        if ($diff < 86400) return floor($diff / 3600) . ' ' . Lang::get('hours_ago');
        if ($diff < 2592000) return floor($diff / 86400) . ' ' . Lang::get('days_ago');
        return self::formatDate($date);
    }

    public static function priorityBadge(string $priority): string
    {
        $map = [
            'low'    => ['label' => Lang::get('priority_low'),    'class' => 'badge-low'],
            'medium' => ['label' => Lang::get('priority_medium'), 'class' => 'badge-medium'],
            'high'   => ['label' => Lang::get('priority_high'),   'class' => 'badge-high'],
            'urgent' => ['label' => Lang::get('priority_urgent'), 'class' => 'badge-urgent'],
        ];
        $p = $map[$priority] ?? $map['medium'];
        return '<span class="badge priority-badge ' . $p['class'] . '">' . self::e($p['label']) . '</span>';
    }

    public static function avatar(array $user, int $size = 32): string
    {
        if (!empty($user['avatar'])) {
            return '<img src="/api/avatar/' . self::e($user['id']) . '" class="avatar" width="' . $size . '" height="' . $size . '" style="width:' . $size . 'px;height:' . $size . 'px" alt="">';
        }
        $initials = mb_strtoupper(mb_substr($user['username'], 0, 1));
        $colors = ['#4f46e5','#7c3aed','#db2777','#dc2626','#d97706','#059669','#0284c7'];
        $color = $colors[crc32($user['username']) % count($colors)];
        return '<span class="avatar-initials" style="background:' . $color . ';width:' . $size . 'px;height:' . $size . 'px;font-size:' . floor($size * 0.4) . 'px">' . self::e($initials) . '</span>';
    }

    public static function uploadFile(array $file, int $taskId): array|false
    {
        $maxSize = 20 * 1024 * 1024; // 20MB
        $allowed = ['jpg','jpeg','png','gif','pdf','doc','docx','xls','xlsx','txt','zip','csv'];

        if ($file['error'] !== UPLOAD_ERR_OK) return false;
        if ($file['size'] > $maxSize) return false;

        $ext = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION));
        if (!in_array($ext, $allowed)) return false;

        $dir = UPLOAD_PATH . '/' . $taskId;
        if (!is_dir($dir)) mkdir($dir, 0750, true);

        $allowedMimes = [
            'image/jpeg', 'image/png', 'image/gif',
            'application/pdf',
            'application/msword',
            'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
            'application/vnd.ms-excel',
            'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
            'text/plain', 'text/csv',
            'application/zip', 'application/x-zip-compressed', 'application/x-zip',
        ];

        // MIME-check on tmp_name before moving (avoids TOCTOU window on disk)
        $finfo = new \finfo(FILEINFO_MIME_TYPE);
        $mime  = $finfo->file($file['tmp_name']);
        if (!in_array($mime, $allowedMimes)) return false;

        $filename = bin2hex(random_bytes(16)) . '.' . $ext;
        $dest = $dir . '/' . $filename;

        if (!move_uploaded_file($file['tmp_name'], $dest)) return false;

        return [
            'filename'      => $taskId . '/' . $filename,
            'original_name' => $file['name'],
            'file_size'     => $file['size'],
            'mime_type'     => $mime,
        ];
    }

    /**
     * Pick a username that does not collide with any existing user, suffixing
     * "(2)", "(3)", … if the desired one is taken. Pass `$selfId` to exclude
     * the row currently being updated (so re-saving the same user keeps its
     * stable username when the only "match" is the user themselves).
     *
     * Used by every code path that creates or renames a local user (group
     * admin user creation, invitation-accept registration, OIDC/LDAP JIT
     * provisioning) so `Mailer::notifyMentioned`'s `@username` lookup cannot
     * be hijacked by a duplicate username planted in another tenant.
     */
    public static function resolveUniqueUsername(string $desired, int $selfId = 0): string
    {
        $desired = trim($desired);
        if ($desired === '') $desired = 'user';
        $db = Database::getInstance();
        $candidate = $desired;
        $i = 2;
        while (true) {
            $row = $db->fetch(
                'SELECT id FROM users WHERE username = ? AND id != ? LIMIT 1',
                [$candidate, $selfId]
            );
            if (!$row) return $candidate;
            $candidate = $desired . ' (' . $i . ')';
            $i++;
            // Hard cap: extremely unlikely to hit, but bound the loop.
            if ($i > 999) return $desired . ' (' . random_int(1000, 99999) . ')';
        }
    }

    /**
     * Mask an email address for safe inclusion in log lines.
     * "alice@example.com" → "a***@example.com". DSGVO-friendly: keeps the log
     * useful for correlation without persisting the full identifier.
     */
    public static function maskEmail(?string $email): string
    {
        if (!$email || !str_contains($email, '@')) return '<unknown>';
        [$local, $domain] = explode('@', $email, 2);
        if ($local === '') return '@' . $domain;
        $first = mb_substr($local, 0, 1);
        return $first . str_repeat('*', max(1, mb_strlen($local) - 1)) . '@' . $domain;
    }

    /**
     * Resolve the timezone for the currently active context.
     * Order: explicit user override > logged-in user's setting > admin default > 'UTC'.
     * Returns a valid IANA identifier.
     */
    public static function currentTimezone(?string $override = null): string
    {
        if ($override && self::isValidTimezone($override)) return $override;

        $userTz = Auth::check() ? (Auth::user()['timezone'] ?? '') : '';
        if ($userTz && self::isValidTimezone($userTz)) return $userTz;

        $adminTz = self::getSetting('app_default_timezone', 'UTC');
        if (self::isValidTimezone($adminTz)) return $adminTz;

        return 'UTC';
    }

    /**
     * Resolve the timezone to use for an outbound email.
     * Same precedence order as currentTimezone() but explicit per-recipient.
     */
    public static function timezoneForUser(?array $recipientUser = null): string
    {
        if ($recipientUser && !empty($recipientUser['timezone']) && self::isValidTimezone($recipientUser['timezone'])) {
            return $recipientUser['timezone'];
        }
        $adminTz = self::getSetting('app_default_timezone', 'UTC');
        return self::isValidTimezone($adminTz) ? $adminTz : 'UTC';
    }

    public static function isValidTimezone(string $tz): bool
    {
        if ($tz === '') return false;
        try { new \DateTimeZone($tz); return true; }
        catch (\Throwable) { return false; }
    }

    /**
     * Resolve the language to use for an outbound email.
     * Order: explicit override > domain mapping for the recipient > admin default > Lang::FALLBACK.
     */
    public static function languageForEmail(?string $recipientEmail, ?string $override = null): string
    {
        if ($override && Lang::isSupported($override)) return $override;
        if ($recipientEmail && str_contains($recipientEmail, '@')) {
            $domain = strtolower(trim(substr($recipientEmail, strrpos($recipientEmail, '@') + 1)));
            if ($domain !== '') {
                try {
                    $row = Database::getInstance()->fetch(
                        'SELECT language FROM domain_languages WHERE domain = ?',
                        [$domain]
                    );
                    if ($row && Lang::isSupported($row['language'])) return $row['language'];
                } catch (\Throwable) { /* table may not exist yet */ }
            }
        }
        $admin = self::getSetting('app_default_language', '');
        if ($admin !== '' && Lang::isSupported($admin)) return $admin;
        return Lang::FALLBACK;
    }

    /** @var array<string,string> Cache for rows that exist in the DB. Missing keys are NOT cached so callers with different defaults get the right answer. */
    private static array $settingsCache = [];

    public static function getSetting(string $key, string $default = ''): string
    {
        if (array_key_exists($key, self::$settingsCache)) return self::$settingsCache[$key];
        $db = Database::getInstance();
        $row = $db->fetch('SELECT value FROM settings WHERE key = ?', [$key]);
        if ($row) {
            self::$settingsCache[$key] = $row['value'];
            return $row['value'];
        }
        return $default;
    }

    public static function setSetting(string $key, string $value): void
    {
        $db = Database::getInstance();
        $db->execute(
            'INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value',
            [$key, $value]
        );
        self::$settingsCache[$key] = $value;
    }

    public static function logActivity(?int $userId, string $action, string $desc, ?int $projectId = null, ?int $taskId = null, ?string $old = null, ?string $new = null): void
    {
        Database::getInstance()->execute(
            'INSERT INTO activity_log (project_id, task_id, user_id, action, description, old_value, new_value) VALUES (?,?,?,?,?,?,?)',
            [$projectId, $taskId, $userId ?: null, $action, $desc, $old, $new]
        );
    }

    public static function createNotification(int $userId, string $title, string $message, string $type = 'info', ?string $relatedType = null, ?int $relatedId = null): void
    {
        Database::getInstance()->execute(
            'INSERT INTO notifications (user_id, title, message, type, related_type, related_id) VALUES (?,?,?,?,?,?)',
            [$userId, $title, $message, $type, $relatedType, $relatedId]
        );
    }

    public static function json(mixed $data, int $code = 200): never
    {
        http_response_code($code);
        header('Content-Type: application/json; charset=utf-8');
        echo json_encode($data, JSON_UNESCAPED_UNICODE);
        exit;
    }

    /**
     * Resolve the calling client's IP address. By default returns `REMOTE_ADDR`
     * verbatim; only when the admin enables `trust_proxy_headers = 1` (because
     * Plenvo sits behind a reverse proxy that always strips and re-adds
     * `X-Forwarded-For`) does this honour the first valid IP from XFF and fall
     * back to REMOTE_ADDR otherwise.
     *
     * Single source of truth used by both `Auth::getClientIp()` and
     * `SecurityLog::clientIp()` — the previous inconsistency between the two
     * was security-audit #1 + #4.
     */
    public static function clientIp(): ?string
    {
        $remote = $_SERVER['REMOTE_ADDR'] ?? null;
        if (self::getSetting('trust_proxy_headers', '0') === '1') {
            $xff = $_SERVER['HTTP_X_FORWARDED_FOR'] ?? '';
            if ($xff !== '') {
                $first = trim(explode(',', $xff)[0]);
                if (filter_var($first, FILTER_VALIDATE_IP)) return $first;
            }
        }
        return $remote && filter_var($remote, FILTER_VALIDATE_IP) ? $remote : null;
    }

    /**
     * Heuristic: is the visitor likely located in the EU/EEA?
     *
     * Used to gate region-specific marketing copy (e.g. GDPR pills) on the
     * landing page. Order of precedence:
     *   1. Country-code header set by a CDN / GeoIP module (Cloudflare's
     *      CF-IPCOUNTRY, ngx_http_geoip_module's GEOIP_COUNTRY_CODE, or a
     *      generic X-Country-Code).
     *   2. Accept-Language: explicit region tag (de-AT → AT) wins, otherwise
     *      the primary language (de, fr, ...) is mapped to "EU language".
     *   3. No signal → false. The pills are irrelevant outside the EU, so we
     *      err on the side of hiding them when the origin is unknown.
     */
    public static function isEuVisitor(): bool
    {
        static $eu = [
            'AT','BE','BG','HR','CY','CZ','DK','EE','FI','FR','DE','GR',
            'HU','IE','IT','LV','LT','LU','MT','NL','PL','PT','RO','SK',
            'SI','ES','SE','IS','LI','NO',
        ];

        foreach (['HTTP_CF_IPCOUNTRY', 'GEOIP_COUNTRY_CODE', 'HTTP_X_COUNTRY_CODE'] as $key) {
            $cc = strtoupper(substr((string)($_SERVER[$key] ?? ''), 0, 2));
            if ($cc === '' || $cc === 'XX' || $cc === 'T1') continue;
            return in_array($cc, $eu, true);
        }

        $al = trim((string)($_SERVER['HTTP_ACCEPT_LANGUAGE'] ?? ''));
        if ($al !== '' && preg_match('/^([a-z]{2,3})(?:[-_]([A-Za-z]{2}))?/i', $al, $m)) {
            $region = isset($m[2]) ? strtoupper($m[2]) : '';
            if ($region !== '') return in_array($region, $eu, true);
            $euLangs = [
                'de','fr','it','es','nl','pl','sv','da','fi','cs','sk','hu',
                'ro','bg','hr','et','lv','lt','sl','mt','ga','el','pt','is','no',
            ];
            return in_array(strtolower($m[1]), $euLangs, true);
        }

        return false;
    }

    public static function baseUrl(): string
    {
        $configured = self::getSetting('app_url', '');
        if ($configured !== '') {
            return rtrim($configured, '/');
        }
        // Honour the reverse-proxy `X-Forwarded-Proto` header so reset / verify
        // links generated server-side do not regress to `http://` when Plenvo
        // is terminated behind an HTTPS proxy. Trust is gated by the same
        // admin-controlled `trust_proxy_headers` setting that governs IP
        // detection (security-audit #19).
        $isHttps = !empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off';
        if (!$isHttps && self::getSetting('trust_proxy_headers', '0') === '1'
            && (($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '') === 'https')) {
            $isHttps = true;
        }
        return ($isHttps ? 'https' : 'http') . '://' . ($_SERVER['HTTP_HOST'] ?? 'localhost');
    }

    /**
     * Batch-load assignees for a list of tasks (modifies tasks in-place).
     * Sets $task['assignees'] (array) and backward-compat $task['assignee_name'] / $task['assigned_to'].
     */
    public static function loadAssignees(Database $db, array &$tasks): void
    {
        if (empty($tasks)) return;
        $taskIds = array_column($tasks, 'id');
        $ph = implode(',', array_fill(0, count($taskIds), '?'));
        $rows = $db->fetchAll(
            "SELECT ta.task_id, ta.user_id, ta.external_id,
                    COALESCE(pe.name, ta.external_name) AS external_name,
                    pe.token AS external_token,
                    u.username, u.avatar
             FROM task_assignees ta
             LEFT JOIN users u ON u.id = ta.user_id
             LEFT JOIN project_externals pe ON pe.id = ta.external_id
             WHERE ta.task_id IN ($ph) ORDER BY ta.id",
            $taskIds
        );
        $byTask = [];
        foreach ($rows as $r) {
            $byTask[$r['task_id']][] = $r;
        }
        foreach ($tasks as &$t) {
            $t['assignees'] = $byTask[$t['id']] ?? [];
            $first = $t['assignees'][0] ?? null;
            $t['assignee_name'] = $first ? ($first['username'] ?? $first['external_name']) : null;
            $t['assigned_to']   = $first['user_id'] ?? null;
        }
        unset($t);
    }

    /**
     * Render assignee chips HTML (for inline display in detail / list views).
     * $size = avatar size in px.
     */
    public static function assigneeChips(array $assignees, int $size = 20, bool $withRemove = false): string
    {
        if (empty($assignees)) return '<span class="text-muted">—</span>';
        $removeTitle = self::e(Lang::get('remove'));
        $html = '<div class="assignee-chips">';
        foreach ($assignees as $a) {
            if (!empty($a['user_id'])) {
                $avatarUser = ['username' => $a['username'], 'avatar' => $a['avatar'], 'id' => $a['user_id']];
                $rmBtn = $withRemove
                    ? '<button type="button" class="assignee-chip-remove" data-action="remove-user" data-id="' . (int)$a['user_id'] . '" title="' . $removeTitle . '" aria-label="' . $removeTitle . '">×</button>'
                    : '';
                $html .= '<div class="assignee-chip">'
                    . self::avatar($avatarUser, $size)
                    . '<span>' . self::e($a['username']) . '</span>'
                    . $rmBtn
                    . '</div>';
            } else {
                $extName = (string)($a['external_name'] ?? '');
                $initial = mb_strtoupper(mb_substr($extName, 0, 1));
                $rmBtn = '';
                if ($withRemove && !empty($a['external_id'])) {
                    $rmBtn = '<button type="button" class="assignee-chip-remove" data-action="remove-ext" data-id="' . (int)$a['external_id'] . '" title="' . $removeTitle . '" aria-label="' . $removeTitle . '">×</button>';
                }
                $html .= '<div class="assignee-chip assignee-chip--ext">'
                    . '<span class="avatar-initials avatar-ext" style="width:' . $size . 'px;height:' . $size . 'px;font-size:' . floor($size * 0.4) . 'px">'
                    . self::e($initial) . '</span>'
                    . '<span>' . self::e($extName) . '</span>'
                    . '<span class="badge-ext">' . Lang::get('external_short') . '</span>'
                    . $rmBtn
                    . '</div>';
            }
        }
        $html .= '</div>';
        return $html;
    }
}
