/**
 * Main JavaScript — Informatika UNTAN 2026
 * OOP modules:
 *   DirectusClient      — API base + URL builders + fetch
 *   FileDownloader      — blob-based force-download with fallback
 *   DocumentFormatter   — pure formatting / mapping / escaping utilities
 *   DocumentCardView    — renders a single card and binds its actions
 *   DocumentRepository  — controller for the repository section
 *   CounterAnimation    — scroll-triggered number animation
 */

/* ===========================================
   DirectusClient
   =========================================== */
class DirectusClient {
    static API_BASE = 'https://api.ifuntanhub.dev';

    static itemsUrl(collection) {
        return `${this.API_BASE}/items/${collection}?limit=-1`;
    }

    static assetUrl(uuid) {
        return `${this.API_BASE}/assets/${uuid}`;
    }

    static viewerUrl(item) {
        const type = DocumentFormatter.fileTypeFromName(item.nama_berkas);
        const params = new URLSearchParams({
            id: item.file,
            name: item.nama_berkas,
            type,
        });
        return `viewer.html?${params.toString()}`;
    }

    static async fetchPublished(collection) {
        const response = await fetch(this.itemsUrl(collection));
        if (!response.ok) throw new Error(`Gagal memuat koleksi ${collection}.`);
        const body = await response.json();
        const items = body.data || [];
        return items.filter(item => item.status === 'published');
    }
}

/* ===========================================
   FileDownloader
   =========================================== */
class FileDownloader {
    static async download(url, filename) {
        try {
            const response = await fetch(url);
            if (!response.ok) throw new Error('Gagal mengunduh berkas.');
            const blob = await response.blob();
            const blobUrl = URL.createObjectURL(blob);
            this._click(blobUrl, filename);
            URL.revokeObjectURL(blobUrl);
        } catch (err) {
            console.warn('Unduh via Blob gagal, beralih ke parameter Directus:', err);
            const fallbackUrl = url.includes('?') ? `${url}&download` : `${url}?download`;
            this._click(fallbackUrl, filename);
        }
    }

    static _click(href, filename) {
        const a = document.createElement('a');
        a.style.display = 'none';
        a.href = href;
        a.download = filename;
        document.body.appendChild(a);
        a.click();
        document.body.removeChild(a);
    }
}

/* ===========================================
   DocumentFormatter
   =========================================== */
class DocumentFormatter {
    static MONTHS_ID = ['Jan', 'Feb', 'Mar', 'Apr', 'Mei', 'Jun', 'Jul', 'Agu', 'Sep', 'Okt', 'Nov', 'Des'];

    static formatDate(iso) {
        if (!iso) return '';
        const date = new Date(iso);
        if (Number.isNaN(date.getTime())) return '';
        const day = String(date.getDate()).padStart(2, '0');
        const month = this.MONTHS_ID[date.getMonth()];
        return `${day} ${month} ${date.getFullYear()}`;
    }

    /**
     * Map a document's parsed tags to one of the fixed UI categories.
     * Uses the tags array for precise matching instead of raw string search.
     */
    static CATEGORY_RULES = [
        { key: 'skripsi',  match: ['skripsi', 'tugas akhir', 'ta'] },
        { key: 'akademik', match: ['akademik', 'kalender', 'kaldik'] },
        { key: 'praktik',  match: ['praktik', 'praktek', 'kerja praktik', 'kp'] },
    ];

    static categoryFromTags(tags) {
        if (!tags || tags.length === 0) return 'umum';
        const lower = tags.map(t => t.toLowerCase());
        for (const rule of this.CATEGORY_RULES) {
            if (rule.match.some(m => lower.includes(m))) return rule.key;
        }
        return 'umum';
    }

    static fileTypeFromName(name) {
        const lower = (name || '').toLowerCase();
        if (lower.endsWith('.docx') || lower.endsWith('.doc')) return 'docx';
        return 'pdf';
    }

    static downloadFilename(name, type) {
        const ext = `.${type}`;
        return name.toLowerCase().endsWith(ext) ? name : `${name}${ext}`;
    }

    // Escape API-supplied strings before splicing into innerHTML templates.
    static escapeHtml(str) {
        if (str == null) return '';
        return String(str)
            .replace(/&/g, '&amp;')
            .replace(/</g, '&lt;')
            .replace(/>/g, '&gt;')
            .replace(/"/g, '&quot;')
            .replace(/'/g, '&#39;');
    }

    // Escape values destined for HTML attribute slots (href, etc.).
    static escapeAttr(str) {
        return this.escapeHtml(str);
    }

    /**
     * Parse the `tag` field from the API.
     * Handles two formats:
     *   1. Comma-separated string:  "akademik,panduan,2024"
     *   2. JSON array string:       '["panduan","proposal"]'
     * Returns an array of trimmed, non-empty tag strings.
     */
    static parseTags(raw) {
        if (!raw) return [];
        const str = String(raw).trim();
        if (!str) return [];

        // Attempt JSON parse first (handles '["a","b"]' format).
        if (str.startsWith('[')) {
            try {
                const arr = JSON.parse(str);
                if (Array.isArray(arr)) {
                    return arr.map(t => String(t).trim()).filter(Boolean);
                }
            } catch { /* fall through to comma-split */ }
        }

        // Comma-separated fallback.
        return str.split(',').map(t => t.trim()).filter(Boolean);
    }
}

/* ===========================================
   DocumentCardView
   =========================================== */
class DocumentCardView {
    constructor(item) {
        this.item = item;
        this.title = item.nama_berkas;
        this.tags = DocumentFormatter.parseTags(item.tag);
        this.category = DocumentFormatter.categoryFromTags(this.tags);
        this.fileType = DocumentFormatter.fileTypeFromName(this.title);
        this.assetUrl = DirectusClient.assetUrl(item.file);
        this.viewerUrl = DirectusClient.viewerUrl(item);
        this.downloadName = DocumentFormatter.downloadFilename(this.title, this.fileType);
        this.dateIso = item.tanggal_upload || '';
        this.dateKey = this.dateIso ? this.dateIso.substring(0, 10) : '';
    }

    render() {
        const card = document.createElement('article');
        card.className = 'document-card';
        card.dataset.date = this.dateKey;

        const typeUpper = this.fileType.toUpperCase();
        const typeClass = this.fileType === 'pdf' ? 'doc-type-pdf' : 'doc-type-docx';
        const iconClass = this.fileType === 'pdf' ? 'fas fa-file-pdf' : 'fas fa-file-word';
        const formattedDate = DocumentFormatter.formatDate(this.dateIso);

        const safeTitle = DocumentFormatter.escapeHtml(this.title);
        const safeViewerUrl = DocumentFormatter.escapeAttr(this.viewerUrl);
        const safeAssetUrl = DocumentFormatter.escapeAttr(this.assetUrl);

        // Build tag badges HTML.
        const tagBadgesHtml = this.tags.length
            ? `<div class="doc-tags"><i class="fas fa-tags doc-tags-icon"></i>${this.tags.map(t => `<span class="doc-tag">${DocumentFormatter.escapeHtml(t)}</span>`).join('')}</div>`
            : '';

        card.innerHTML = `
            <div class="doc-type-icon ${typeClass}"><i class="${iconClass}"></i></div>
            <h3 class="doc-title">${safeTitle}</h3>
            <div class="doc-meta">
                <span class="doc-badge">${typeUpper}</span>
                <span class="doc-badge date"><i class="far fa-calendar-alt"></i> ${formattedDate}</span>
            </div>
            ${tagBadgesHtml}
            <div class="doc-actions">
                <a href="${safeViewerUrl}" class="btn-action-primary" target="_blank" rel="noopener"><span data-tp-i18n="card_open">Buka Berkas</span> <i class="fas fa-external-link-alt"></i></a>
                <a href="${safeAssetUrl}" class="btn-action-outline"><span data-tp-i18n="card_download">Unduh Berkas</span> <i class="fas fa-download"></i></a>
            </div>
        `;

        card.querySelector('.btn-action-outline').addEventListener('click', (e) => {
            e.preventDefault();
            FileDownloader.download(this.assetUrl, this.downloadName);
        });

        // Prefetch the asset when the user hovers over the card so the
        // browser cache already has the file by the time the viewer loads.
        let prefetched = false;
        card.addEventListener('mouseenter', () => {
            if (prefetched) return;
            prefetched = true;
            const link = document.createElement('link');
            link.rel = 'prefetch';
            link.as = 'fetch';
            link.crossOrigin = 'anonymous';
            link.href = this.assetUrl;
            document.head.appendChild(link);
        }, { once: true });

        return card;
    }
}

/* ===========================================
   CustomDropdown
   =========================================== */
class CustomDropdown {
    constructor({ container, options, defaultValue, onChange, prefixIcon }) {
        this.container = container;
        this.options = options; // Array of { value, text, i18nKey }
        this.onChange = onChange;
        this.currentValue = defaultValue || options[0]?.value;
        this.prefixIcon = prefixIcon; // optional class name like 'fas fa-sort'
        this.isOpen = false;

        this.elements = {};
        this._build();
        this._bindEvents();
    }

    _build() {
        this.container.innerHTML = '';
        this.container.classList.add('custom-dropdown');

        // Trigger Button
        const trigger = document.createElement('button');
        trigger.className = 'custom-dropdown-trigger';
        trigger.setAttribute('aria-haspopup', 'listbox');
        trigger.setAttribute('aria-expanded', 'false');
        trigger.type = 'button';

        const triggerLeft = document.createElement('div');
        triggerLeft.className = 'trigger-left-content';
        triggerLeft.style.display = 'flex';
        triggerLeft.style.alignItems = 'center';
        triggerLeft.style.gap = '10px';

        if (this.prefixIcon) {
            const icon = document.createElement('i');
            icon.className = this.prefixIcon;
            triggerLeft.appendChild(icon);
        }

        const labelSpan = document.createElement('span');
        labelSpan.className = 'trigger-label';
        
        // Find default text
        const defaultOpt = this.options.find(opt => opt.value === this.currentValue) || this.options[0];
        labelSpan.textContent = defaultOpt ? defaultOpt.text : '';
        if (defaultOpt?.i18nKey) {
            labelSpan.setAttribute('data-tp-i18n', defaultOpt.i18nKey);
        }

        triggerLeft.appendChild(labelSpan);
        trigger.appendChild(triggerLeft);

        const arrowIcon = document.createElement('i');
        arrowIcon.className = 'fas fa-chevron-down trigger-arrow';

        trigger.appendChild(arrowIcon);
        this.container.appendChild(trigger);

        // Menu wrapper
        const menu = document.createElement('div');
        menu.className = 'custom-dropdown-menu';
        menu.setAttribute('role', 'listbox');

        // Populate options
        this.options.forEach(opt => {
            const item = document.createElement('div');
            item.className = 'custom-dropdown-item' + (opt.value === this.currentValue ? ' active' : '');
            item.setAttribute('role', 'option');
            item.setAttribute('data-value', opt.value);
            
            const itemText = document.createElement('span');
            itemText.textContent = opt.text;
            if (opt.i18nKey) {
                itemText.setAttribute('data-tp-i18n', opt.i18nKey);
            }
            
            item.appendChild(itemText);
            menu.appendChild(item);
        });

        this.container.appendChild(menu);

        this.elements = { trigger, labelSpan, menu };
    }

    _bindEvents() {
        // Toggle dropdown on trigger click
        this.elements.trigger.addEventListener('click', (e) => {
            e.stopPropagation();
            this.toggle();
        });

        // Handle item selection
        this.elements.menu.addEventListener('click', (e) => {
            const item = e.target.closest('.custom-dropdown-item');
            if (!item) return;

            const value = item.getAttribute('data-value');
            this.select(value);
            this.close();
        });

        // Close when clicking outside
        document.addEventListener('click', () => {
            this.close();
        });

        // Close on escape key
        document.addEventListener('keydown', (e) => {
            if (e.key === 'Escape') this.close();
        });
    }

    toggle() {
        if (this.isOpen) {
            this.close();
        } else {
            this.open();
        }
    }

    open() {
        // Close other custom dropdowns
        document.querySelectorAll('.custom-dropdown.active').forEach(dropdown => {
            if (dropdown !== this.container) {
                dropdown.classList.remove('active');
                dropdown.querySelector('.custom-dropdown-trigger')?.setAttribute('aria-expanded', 'false');
            }
        });

        this.isOpen = true;
        this.container.classList.add('active');
        this.elements.trigger.setAttribute('aria-expanded', 'true');
    }

    close() {
        this.isOpen = false;
        this.container.classList.remove('active');
        this.elements.trigger.setAttribute('aria-expanded', 'false');
    }

    select(value) {
        if (value === this.currentValue) return;

        this.currentValue = value;
        
        // Update active class in menu items
        this.elements.menu.querySelectorAll('.custom-dropdown-item').forEach(item => {
            const isCurrent = item.getAttribute('data-value') === value;
            item.classList.toggle('active', isCurrent);
        });

        // Update trigger label
        const selectedOpt = this.options.find(opt => opt.value === value);
        if (selectedOpt) {
            this.elements.labelSpan.textContent = selectedOpt.text;
            if (selectedOpt.i18nKey) {
                this.elements.labelSpan.setAttribute('data-tp-i18n', selectedOpt.i18nKey);
            } else {
                this.elements.labelSpan.removeAttribute('data-tp-i18n');
            }
        }

        // Trigger callback
        if (typeof this.onChange === 'function') {
            this.onChange(value);
        }
    }

    setValue(value) {
        this.select(value);
    }

    getValue() {
        return this.currentValue;
    }
}

/* ===========================================
   DocumentRepository
   =========================================== */
class DocumentRepository {
    constructor() {
        this.gridContainer = document.getElementById('document-grid');
        this.filterWrapper = document.getElementById('tag-filter-wrapper');
        this.mobileFilterWrapper = document.getElementById('mobile-tag-filter-wrapper');
        this.searchInput = document.getElementById('doc-search');
        this.dateStartInput = document.getElementById('date-start');
        this.dateEndInput = document.getElementById('date-end');
        this.sortFilterWrapper = document.getElementById('sort-filter-wrapper');

        // Hero stat values (filled from real API data after fetch).
        this.statActiveEl = document.getElementById('stat-active-value');
        this.statUpdatesEl = document.getElementById('stat-updates-value');

        this.cards = [];
        this.state = {
            filter: 'all',   // 'all' or a specific tag string
            search: '',
            startDate: '',
            endDate: '',
            sort: 'newest',
        };
    }

    async init() {
        if (!this.gridContainer) return;
        this._bindControls();
        try {
            const items = await DirectusClient.fetchPublished('berkas');
            this._renderCards(items);
            this._buildCategoryFilters(items);
            this._renderStats(items);
            this._applySort();
            this._applyFilters();
        } catch (err) {
            console.error(err);
            this._renderError();
            this._renderStatsError();
        }
    }

    // Days within which an upload counts as a "recent update".
    static RECENT_DAYS = 30;

    // Populate the hero stats from real data:
    //   Dokumen Aktif      → number of published documents
    //   Pembaruan Terbaru  → uploads within the last RECENT_DAYS days
    _renderStats(items) {
        const active = items.length;

        const windowMs = DocumentRepository.RECENT_DAYS * 24 * 60 * 60 * 1000;
        const now = Date.now();
        const recent = items.filter(item => {
            if (!item.tanggal_upload) return false;
            const t = new Date(item.tanggal_upload).getTime();
            return !Number.isNaN(t) && now - t <= windowMs;
        }).length;

        CounterAnimation.animateValue(this.statActiveEl, active);
        CounterAnimation.animateValue(this.statUpdatesEl, recent);
    }

    _renderStatsError() {
        if (this.statActiveEl) this.statActiveEl.textContent = '—';
        if (this.statUpdatesEl) this.statUpdatesEl.textContent = '—';
    }
    /**
     * Fixed category definitions for the sidebar filter.
     * Each has a key, an i18n key for bilingual labels, and a fallback label.
     */
    static CATEGORIES = [
        { key: 'all',      i18n: 'filter_all',      label: 'Semua Berkas' },
        { key: 'skripsi',  i18n: 'filter_skripsi',  label: 'Panduan Skripsi' },
        { key: 'akademik', i18n: 'filter_akademik', label: 'Kalender Akademik' },
        { key: 'praktik',  i18n: 'filter_praktik',  label: 'Kerja Praktik' },
        { key: 'umum',     i18n: 'filter_umum',     label: 'Formulir Umum' },
    ];

    /**
     * Build category filter controls:
     *  – Desktop: sidebar buttons (hidden on mobile via CSS)
     *  – Mobile:  a <select> dropdown (hidden on desktop via CSS)
     * Both stay in sync through a shared _setFilter() helper.
     */
    _buildCategoryFilters(items) {
        if (!this.filterWrapper) return;

        // Clear skeleton placeholder.
        this.filterWrapper.innerHTML = '';

        // ── Desktop buttons ──
        const btnGroup = document.createElement('div');
        btnGroup.className = 'filter-btn-group';

        DocumentRepository.CATEGORIES.forEach(({ key, i18n, label }) => {
            const isActive = key === 'all';
            const btn = document.createElement('button');
            btn.className = 'doc-filter-btn' + (isActive ? ' active' : '');
            btn.dataset.filter = key;
            btn.textContent = (window.tpT?.(i18n)) || label;
            btn.setAttribute('data-tp-i18n', i18n);

            btn.addEventListener('click', () => this._setFilter(key));
            btnGroup.appendChild(btn);
        });

        this.filterWrapper.appendChild(btnGroup);

        // ── Mobile Custom UI Dropdown ──
        if (this.mobileFilterWrapper) {
            const dropdownOptions = DocumentRepository.CATEGORIES.map(({ key, i18n, label }) => ({
                value: key,
                i18nKey: i18n,
                text: (window.tpT?.(i18n)) || label
            }));

            this._mobileDropdown = new CustomDropdown({
                container: this.mobileFilterWrapper,
                options: dropdownOptions,
                defaultValue: this.state.filter,
                onChange: (value) => this._setFilter(value)
            });
        }

        // Store refs for syncing.
        this._filterBtnGroup = btnGroup;

        // Re-translate if i18n is active.
        window.applyTp?.(this.filterWrapper);
    }

    /**
     * Shared filter setter — keeps buttons + dropdown in sync.
     */
    _setFilter(key) {
        this.state.filter = key;

        // Sync buttons.
        if (this._filterBtnGroup) {
            this._filterBtnGroup.querySelectorAll('.doc-filter-btn')
                .forEach(b => b.classList.toggle('active', b.dataset.filter === key));
        }

        // Sync custom dropdown.
        if (this._mobileDropdown && this._mobileDropdown.getValue() !== key) {
            this._mobileDropdown.setValue(key);
        }

        this._applyFilters();
    }

    _bindControls() {
        // Tag filter buttons are built dynamically after API fetch
        // (see _buildTagFilters). Only bind search/date/sort here.

        this.searchInput?.addEventListener('input', (e) => {
            this.state.search = e.target.value.toLowerCase().trim();
            this._applyFilters();
        });

        this.dateStartInput?.addEventListener('change', (e) => {
            this.state.startDate = e.target.value;
            this._applyFilters();
        });

        this.dateEndInput?.addEventListener('change', (e) => {
            this.state.endDate = e.target.value;
            this._applyFilters();
        });

        if (this.sortFilterWrapper) {
            const sortOptions = [
                { value: 'newest', i18nKey: 'sort_newest', text: (window.tpT?.('sort_newest')) || 'Terbaru' },
                { value: 'oldest', i18nKey: 'sort_oldest', text: (window.tpT?.('sort_oldest')) || 'Terlama' },
                { value: 'az',     i18nKey: 'sort_az',     text: (window.tpT?.('sort_az'))     || 'Nama A-Z' }
            ];

            this._sortDropdown = new CustomDropdown({
                container: this.sortFilterWrapper,
                options: sortOptions,
                defaultValue: this.state.sort,
                prefixIcon: 'fas fa-sort-amount-down',
                onChange: (value) => {
                    this.state.sort = value;
                    this._applySort();
                    this._applyFilters();
                }
            });

            window.applyTp?.(this.sortFilterWrapper);
        }
    }

    _renderCards(items) {
        this.gridContainer.innerHTML = '';

        if (items.length === 0) {
            this._renderState('repo-empty', 'fas fa-folder-open', '#cbd5e1', '#64748b',
                'state_empty', 'Belum ada dokumen yang tersedia di repositori.');
            return;
        }

        this.cards = items.map(item => {
            const view = new DocumentCardView(item);
            const element = view.render();
            this.gridContainer.appendChild(element);
            return { view, element };
        });

        // Translate the freshly-rendered card labels (Buka/Unduh Berkas).
        window.applyTp?.(this.gridContainer);
    }

    _renderError() {
        this._renderState('repo-error', 'fas fa-exclamation-triangle', '#f87171', '#ef4444',
            'state_error', 'Gagal memuat berkas. Silakan periksa koneksi internet Anda atau coba lagi nanti.');
    }

    // Build the empty/error placeholder via safe DOM APIs (no innerHTML).
    // `i18nKey` lets the Tapops i18n layer re-translate the message on toggle.
    _renderState(className, iconClass, iconColor, textColor, i18nKey, message) {
        this.cards = [];
        this.gridContainer.innerHTML = '';

        const wrapper = document.createElement('div');
        wrapper.className = className;
        Object.assign(wrapper.style, {
            gridColumn: '1 / -1',
            textAlign: 'center',
            padding: '60px 20px',
            color: textColor,
            fontWeight: '700',
            fontSize: '1rem',
            display: 'flex',
            flexDirection: 'column',
            alignItems: 'center',
            gap: '15px',
        });

        const icon = document.createElement('i');
        icon.className = iconClass;
        Object.assign(icon.style, { fontSize: '3rem', color: iconColor });

        const span = document.createElement('span');
        if (i18nKey) span.setAttribute('data-tp-i18n', i18nKey);
        span.textContent = (window.tpT?.(i18nKey)) || message;

        wrapper.appendChild(icon);
        wrapper.appendChild(span);
        this.gridContainer.appendChild(wrapper);
    }

    _applyFilters() {
        this.cards.forEach(({ view, element }) => {
            if (this._cardMatches(view)) {
                element.style.display = 'flex';
                setTimeout(() => {
                    element.style.opacity = '1';
                    element.style.transform = 'translateY(0)';
                }, 10);
            } else {
                element.style.display = 'none';
                element.style.opacity = '0';
                element.style.transform = 'translateY(10px)';
            }
        });
    }

    _cardMatches(view) {
        const { filter, search } = this.state;
        const matchesCategory = filter === 'all' || view.category === filter;
        const matchesTitle = view.title.toLowerCase().includes(search);
        const matchesTags = search
            ? view.tags.some(t => t.toLowerCase().includes(search))
            : false;
        const matchesSearch = matchesTitle || matchesTags;
        return matchesCategory && matchesSearch && this._dateMatches(view.dateKey);
    }

    _dateMatches(dateKey) {
        const { startDate, endDate } = this.state;
        if (!dateKey) return !startDate && !endDate;
        const cardDate = new Date(dateKey);
        if (startDate && cardDate < new Date(startDate)) return false;
        if (endDate) {
            const end = new Date(endDate);
            end.setHours(23, 59, 59, 999);
            if (cardDate > end) return false;
        }
        return true;
    }

    _applySort() {
        const { sort } = this.state;
        this.cards.sort((a, b) => {
            if (sort === 'newest') return b.view.dateKey.localeCompare(a.view.dateKey);
            if (sort === 'oldest') return a.view.dateKey.localeCompare(b.view.dateKey);
            if (sort === 'az') return a.view.title.toLowerCase().localeCompare(b.view.title.toLowerCase());
            return 0;
        });
        this.cards.forEach(({ element }) => this.gridContainer.appendChild(element));
    }
}

/* ===========================================
   CounterAnimation
   =========================================== */
class CounterAnimation {
    static DURATION_MS = 2000;

    init() {
        const counters = document.querySelectorAll('.counter-value[data-target]');
        if (counters.length === 0) return;

        if ('IntersectionObserver' in window) {
            const observer = new IntersectionObserver((entries) => {
                entries.forEach(entry => {
                    if (entry.isIntersecting) {
                        this._animate(entry.target);
                        observer.unobserve(entry.target);
                    }
                });
            }, { threshold: 0.2 });
            counters.forEach(c => observer.observe(c));
        } else {
            counters.forEach(c => this._animate(c));
        }
    }

    _animate(el) {
        const target = Number.parseInt((el.dataset.target || '0').replace(/[^0-9]/g, ''), 10);
        if (Number.isNaN(target)) return;
        CounterAnimation.animateValue(el, target, el.dataset.suffix || '');
    }

    // Reusable easing tween from 0 → target. Callable for stats whose
    // value is only known after an async fetch (no data-target needed).
    static animateValue(el, target, suffix = '') {
        if (!el || Number.isNaN(target)) return;
        const startTime = performance.now();

        const tick = (now) => {
            const progress = Math.min((now - startTime) / CounterAnimation.DURATION_MS, 1);
            const eased = 1 - Math.pow(1 - progress, 3);
            el.textContent = Math.floor(eased * target) + suffix;
            if (progress < 1) requestAnimationFrame(tick);
            else el.textContent = target + suffix;
        };
        requestAnimationFrame(tick);
    }
}

/* ===========================================
   App entry
   =========================================== */
document.addEventListener('DOMContentLoaded', () => {
    new DocumentRepository().init();
});
