// CSP-friendly Alpine build: forbids `Function()` / arbitrary inline
// expressions, so every component lives here as `Alpine.data(...)` and
// templates reference component/method names only. Lets the UI ship a
// CSP without `'unsafe-eval'` or `'unsafe-inline'` (SEC_REVIEW F24).
import Alpine from '@alpinejs/csp';
import 'htmx.org';
import {
    Chart,
    BarController,
    BarElement,
    LineController,
    LineElement,
    PointElement,
    PieController,
    ArcElement,
    CategoryScale,
    LinearScale,
    Tooltip,
    Title,
    Legend,
    Filler,
} from 'chart.js';

function applyTheme(theme) {
    if (theme === 'dark') {
        document.documentElement.classList.add('dark');
    } else {
        document.documentElement.classList.remove('dark');
    }
    try {
        localStorage.setItem('irdb-theme', theme);
    } catch (e) {
        /* ignore */
    }
}

document.addEventListener('click', (e) => {
    const target = e.target.closest('[data-theme-toggle]');
    if (!target) return;
    const next = document.documentElement.classList.contains('dark') ? 'light' : 'dark';
    applyTheme(next);
});

document.body.addEventListener('htmx:configRequest', (e) => {
    const meta = document.querySelector('meta[name="csrf-token"]');
    if (meta && meta.content) {
        e.detail.headers['X-CSRF-Token'] = meta.content;
    }
});

Chart.register(
    BarController,
    BarElement,
    LineController,
    LineElement,
    PointElement,
    PieController,
    ArcElement,
    CategoryScale,
    LinearScale,
    Tooltip,
    Title,
    Legend,
    Filler,
);

const PIE_COLORS = [
    '#34d399',
    '#22d3ee',
    '#818cf8',
    '#a78bfa',
    '#f472b6',
    '#fb7185',
    '#fbbf24',
    '#a3e635',
    '#2dd4bf',
    '#60a5fa',
];

function chartTheme() {
    const isDark = document.documentElement.classList.contains('dark');
    return {
        tickColor: isDark ? '#94a3b8' : '#475569',
        gridColor: isDark ? 'rgba(148,163,184,0.15)' : 'rgba(148,163,184,0.3)',
        legendColor: isDark ? '#cbd5e1' : '#334155',
    };
}

function hexToRgba(hex, alpha) {
    const m = /^#?([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i.exec(hex);
    if (!m) return hex;
    return `rgba(${parseInt(m[1], 16)},${parseInt(m[2], 16)},${parseInt(m[3], 16)},${alpha})`;
}

function parseSeries(canvas, attr) {
    try {
        return JSON.parse(canvas.dataset[attr] || '[]');
    } catch (e) {
        return [];
    }
}

function renderReportsChart() {
    const canvas = document.getElementById('reports-chart');
    if (!canvas) return;
    const buckets = parseSeries(canvas, 'buckets');

    const labels = buckets.map((b) => (b.hour || '').replace(/.*T(\d{2}).*/, '$1h'));
    const data = buckets.map((b) => b.count || 0);
    const t = chartTheme();

    new Chart(canvas, {
        type: 'bar',
        data: {
            labels,
            datasets: [{
                label: 'reports',
                data,
                backgroundColor: 'rgba(52, 211, 153, 0.7)',
                borderColor: 'rgba(52, 211, 153, 0.9)',
                borderWidth: 1,
                borderRadius: 4,
            }],
        },
        options: {
            responsive: true,
            maintainAspectRatio: false,
            plugins: { legend: { display: false } },
            scales: {
                x: { ticks: { color: t.tickColor }, grid: { color: t.gridColor } },
                y: { ticks: { color: t.tickColor, precision: 0 }, grid: { color: t.gridColor }, beginAtZero: true },
            },
        },
    });
}

function renderPieChart(canvasId) {
    const canvas = document.getElementById(canvasId);
    if (!canvas) return;
    const series = parseSeries(canvas, 'series');
    if (series.length === 0) return;

    const labelKey = canvas.dataset.labelKey || 'name';
    const labels = series.map((row) => String(row[labelKey] ?? ''));
    const data = series.map((row) => row.count || 0);
    const colors = labels.map((_, i) => PIE_COLORS[i % PIE_COLORS.length]);
    const t = chartTheme();

    new Chart(canvas, {
        type: 'pie',
        data: {
            labels,
            datasets: [{
                data,
                backgroundColor: colors,
                borderColor: 'rgba(255,255,255,0.25)',
                borderWidth: 1.5,
            }],
        },
        options: {
            responsive: true,
            maintainAspectRatio: false,
            plugins: {
                legend: {
                    position: 'bottom',
                    labels: { color: t.legendColor, boxWidth: 12, font: { size: 11 } },
                },
                tooltip: {
                    callbacks: {
                        label: (ctx) => {
                            const total = ctx.dataset.data.reduce((acc, v) => acc + v, 0) || 1;
                            const pct = ((ctx.parsed / total) * 100).toFixed(1);
                            return `${ctx.label}: ${ctx.parsed} (${pct}%)`;
                        },
                    },
                },
            },
        },
    });
}

function renderBlockedIpsChart() {
    const canvas = document.getElementById('blocked-ips-chart');
    if (!canvas) return;
    let payload;
    try {
        payload = JSON.parse(canvas.dataset.blocked || '{}');
    } catch (e) {
        return;
    }
    const days = Array.isArray(payload.days) ? payload.days : [];
    const series = Array.isArray(payload.series) ? payload.series : [];
    if (days.length === 0 || series.length === 0) return;

    const labels = days.map((iso) => {
        const d = new Date(iso + 'T00:00:00Z');
        return isNaN(d.getTime()) ? iso : d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
    });
    const datasets = series.map((row, i) => {
        const colour = PIE_COLORS[i % PIE_COLORS.length];
        return {
            label: row.category || '—',
            data: Array.isArray(row.counts) ? row.counts.map((c) => c || 0) : [],
            backgroundColor: hexToRgba(colour, 0.7),
            borderColor: colour,
            borderWidth: 1,
        };
    });
    const t = chartTheme();

    new Chart(canvas, {
        type: 'bar',
        data: { labels, datasets },
        options: {
            responsive: true,
            maintainAspectRatio: false,
            plugins: {
                legend: {
                    position: 'bottom',
                    labels: { color: t.legendColor, boxWidth: 12, font: { size: 11 } },
                },
                tooltip: {
                    callbacks: {
                        title: (items) => (items[0] && days[items[0].dataIndex]) || '',
                    },
                },
            },
            scales: {
                x: { stacked: true, ticks: { color: t.tickColor }, grid: { color: t.gridColor } },
                y: {
                    stacked: true,
                    ticks: { color: t.tickColor, precision: 0 },
                    grid: { color: t.gridColor },
                    beginAtZero: true,
                },
            },
        },
    });
}

document.addEventListener('DOMContentLoaded', () => {
    renderReportsChart();
    renderPieChart('top-reporters-chart');
    renderPieChart('top-categories-chart');
    renderBlockedIpsChart();
});

function getDateLocales() {
    const fallback = document.documentElement.getAttribute('data-irdb-locale-fallback');
    const locales = [];
    if (typeof navigator !== 'undefined' && navigator.language) {
        locales.push(navigator.language);
    }
    if (fallback && fallback.trim()) {
        locales.push(fallback.trim());
    }
    return locales.length > 0 ? locales : undefined;
}

function buildDateFormatters() {
    const locales = getDateLocales();
    return {
        datetime: new Intl.DateTimeFormat(locales, {
            year: 'numeric', month: '2-digit', day: '2-digit',
            hour: '2-digit', minute: '2-digit', second: '2-digit',
        }),
        date: new Intl.DateTimeFormat(locales, {
            year: 'numeric', month: '2-digit', day: '2-digit',
        }),
    };
}

function formatTimes(root) {
    const scope = root && root.querySelectorAll ? root : document;
    const formatters = buildDateFormatters();
    const elements = scope.querySelectorAll('time.irdb-dt[datetime]');
    elements.forEach((el) => {
        const iso = el.getAttribute('datetime');
        if (!iso) return;
        const d = new Date(iso);
        if (isNaN(d.getTime())) return;
        const fmt = el.dataset.irdbDtFormat === 'date' ? formatters.date : formatters.datetime;
        try {
            el.textContent = fmt.format(d);
            if (!el.hasAttribute('title')) {
                el.setAttribute('title', iso);
            }
        } catch (e) {
            /* leave the ISO fallback in place */
        }
    });
}

document.addEventListener('DOMContentLoaded', () => formatTimes(document));
document.body.addEventListener('htmx:afterSettle', (e) => formatTimes(e.target));

// Audit page filter form: ISO ⇄ datetime-local round-tripping. Used to
// live as an inline <script> in audit/index.twig; moved here so the page
// has zero inline JS.
function initAuditFilterForm() {
    const form = document.getElementById('audit-filter-form');
    if (!form) return;
    function pad(n) { return String(n).padStart(2, '0'); }
    function isoToLocalInput(iso) {
        if (!iso) return '';
        const d = new Date(iso);
        if (isNaN(d.getTime())) return '';
        return d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate())
            + 'T' + pad(d.getHours()) + ':' + pad(d.getMinutes()) + ':' + pad(d.getSeconds());
    }
    function localInputToIso(value) {
        if (!value) return '';
        const d = new Date(value);
        if (isNaN(d.getTime())) return '';
        return d.toISOString();
    }
    form.querySelectorAll('input[data-irdb-iso-filter]').forEach((el) => {
        const iso = el.getAttribute('data-irdb-iso-filter');
        if (iso) el.value = isoToLocalInput(iso);
    });
    form.addEventListener('submit', () => {
        form.querySelectorAll('input[type="datetime-local"]').forEach((el) => {
            if (el.value) el.value = localInputToIso(el.value);
        });
    });
}
document.addEventListener('DOMContentLoaded', initAuditFilterForm);

// Sortable tables. Templates mark a table with `data-sortable-table="<id>"`
// and headers with `data-sort-key`; sort state lives in sessionStorage.
const SORT_KEY_PREFIX = 'irdb-sort:';

function readSortState(tableId) {
    try {
        const raw = sessionStorage.getItem(SORT_KEY_PREFIX + tableId);
        if (!raw) return null;
        const parsed = JSON.parse(raw);
        if (parsed && typeof parsed.key === 'string' && (parsed.dir === 'asc' || parsed.dir === 'desc')) {
            return parsed;
        }
    } catch (e) { /* ignore */ }
    return null;
}

function writeSortState(tableId, state) {
    try {
        if (state) {
            sessionStorage.setItem(SORT_KEY_PREFIX + tableId, JSON.stringify(state));
        } else {
            sessionStorage.removeItem(SORT_KEY_PREFIX + tableId);
        }
    } catch (e) { /* ignore */ }
}

function clearAllSortState() {
    try {
        const keys = [];
        for (let i = 0; i < sessionStorage.length; i++) {
            const k = sessionStorage.key(i);
            if (k && k.startsWith(SORT_KEY_PREFIX)) keys.push(k);
        }
        keys.forEach((k) => sessionStorage.removeItem(k));
    } catch (e) { /* ignore */ }
}

function cellSortValue(cell, type) {
    if (!cell) return type === 'number' ? Number.NEGATIVE_INFINITY : '';
    const explicit = cell.getAttribute('data-sort-value');
    if (explicit !== null) return explicit;
    const timeEl = cell.querySelector('time[datetime]');
    if (timeEl) return timeEl.getAttribute('datetime') || '';
    return (cell.textContent || '').trim();
}

function compareValues(a, b, type) {
    if (type === 'number') {
        const na = parseFloat(a);
        const nb = parseFloat(b);
        const aNaN = Number.isNaN(na);
        const bNaN = Number.isNaN(nb);
        if (aNaN && bNaN) return 0;
        if (aNaN) return 1;
        if (bNaN) return -1;
        return na - nb;
    }
    if (type === 'date') {
        const ta = a ? Date.parse(a) : NaN;
        const tb = b ? Date.parse(b) : NaN;
        const aNaN = Number.isNaN(ta);
        const bNaN = Number.isNaN(tb);
        if (aNaN && bNaN) return 0;
        if (aNaN) return 1;
        if (bNaN) return -1;
        return ta - tb;
    }
    return a.localeCompare(b, undefined, { sensitivity: 'base', numeric: true });
}

function findSortableThs(table) {
    const head = table.tHead;
    if (!head || !head.rows.length) return [];
    return Array.from(head.rows[0].cells).filter((c) => c.hasAttribute('data-sort-key'));
}

function applySort(table, key, dir) {
    const ths = findSortableThs(table);
    const th = ths.find((t) => t.getAttribute('data-sort-key') === key);
    if (!th) return;
    const colIndex = th.cellIndex;
    const type = th.getAttribute('data-sort-type') || 'string';

    const tbody = table.tBodies[0];
    if (!tbody) return;

    const allRows = Array.from(tbody.rows);
    const groups = [];
    let current = null;
    for (const row of allRows) {
        if (row.hasAttribute('data-sort-row-detail') && current) {
            current.push(row);
            continue;
        }
        current = [row];
        groups.push(current);
    }

    groups.sort((ga, gb) => {
        const va = cellSortValue(ga[0].cells[colIndex], type);
        const vb = cellSortValue(gb[0].cells[colIndex], type);
        const cmp = compareValues(va, vb, type);
        return dir === 'desc' ? -cmp : cmp;
    });

    const frag = document.createDocumentFragment();
    groups.forEach((g) => g.forEach((r) => frag.appendChild(r)));
    tbody.appendChild(frag);

    ths.forEach((t) => {
        const ind = t.querySelector('.sort-indicator');
        const isActive = t === th;
        if (ind) {
            ind.textContent = isActive ? (dir === 'asc' ? '▲' : '▼') : '↕';
            ind.classList.toggle('text-slate-300', !isActive);
            ind.classList.toggle('dark:text-slate-600', !isActive);
            ind.classList.toggle('text-indigo-600', isActive);
            ind.classList.toggle('dark:text-indigo-400', isActive);
        }
        t.setAttribute('aria-sort', isActive ? (dir === 'asc' ? 'ascending' : 'descending') : 'none');
    });
}

function initSortableTable(table) {
    if (!table || table.dataset.sortableInit === '1') return;
    table.dataset.sortableInit = '1';
    const tableId = table.getAttribute('data-sortable-table');
    if (!tableId) return;
    const ths = findSortableThs(table);
    if (ths.length === 0) return;

    ths.forEach((th) => {
        th.classList.add('cursor-pointer', 'select-none');
        th.addEventListener('click', () => {
            const key = th.getAttribute('data-sort-key');
            const current = readSortState(tableId);
            const dir = current && current.key === key && current.dir === 'asc' ? 'desc' : 'asc';
            writeSortState(tableId, { key, dir });
            applySort(table, key, dir);
        });
    });

    const initial = readSortState(tableId);
    if (initial) {
        applySort(table, initial.key, initial.dir);
    }
}

function initSortableTables(root) {
    const scope = root && root.querySelectorAll ? root : document;
    scope.querySelectorAll('table[data-sortable-table]').forEach(initSortableTable);
}

document.addEventListener('DOMContentLoaded', () => {
    if (window.location && window.location.pathname === '/login') {
        clearAllSortState();
    }
    initSortableTables(document);
});
document.body.addEventListener('htmx:afterSettle', (e) => initSortableTables(e.target));

// ────────────────────────────────────────────────────────────────────
// Alpine.js components — registered up-front because the CSP build only
// resolves `x-data="name"` against names registered via Alpine.data().
// ────────────────────────────────────────────────────────────────────

// Generic open/close toggle (popovers, modals, side-panes). Optional
// `data-initial-open="1"` on the root element starts in the open state.
Alpine.data('toggle', () => ({
    open: false,
    init() {
        if (this.$el.dataset.initialOpen === '1') {
            this.open = true;
        }
    },
    show() { this.open = true; },
    hide() { this.open = false; },
    flip() { this.open = !this.open; },
}));

// Single-row expander — multiple rows share one component, only one row
// expanded at a time. Each expander button passes its own row id.
Alpine.data('rowExpander', () => ({
    open: null,
    toggle(id) {
        this.open = (this.open === id ? null : id);
    },
    isOpen(id) {
        return this.open === id;
    },
}));

// Form section switcher driven by a <select> bound via `x-model="kind"`.
// `data-initial-kind="ip"` chooses the starting branch.
Alpine.data('kindSwitcher', () => ({
    kind: '',
    init() {
        this.kind = this.$el.dataset.initialKind || '';
    },
    isKind(value) {
        return this.kind === value;
    },
}));

// Submit-once guard: disables the submit button on form submission.
Alpine.data('submitGuard', () => ({
    submitting: false,
    onSubmit() { this.submitting = true; },
    get notSubmitting() { return !this.submitting; },
}));

// "Type the magic word to confirm" dangerous-action component used by
// the seed-demo and purge buttons in the settings page.
Alpine.data('dangerousAction', () => ({
    open: false,
    confirm: '',
    submitting: false,
    expectedConfirm: '',
    init() {
        this.expectedConfirm = this.$el.dataset.expectedConfirm || '';
    },
    show() { this.open = true; },
    hide() {
        this.open = false;
        this.confirm = '';
    },
    onSubmit() { this.submitting = true; },
    get blocked() {
        return this.confirm !== this.expectedConfirm || this.submitting;
    },
    get notSubmitting() { return !this.submitting; },
}));

// Login page: initially-open if OIDC is disabled, hidden behind a
// "Use local sign-in" toggle otherwise. The PHP page sets
// `data-initial-open="1"` when there is no OIDC.
Alpine.data('loginForm', () => ({
    open: false,
    init() {
        if (this.$el.dataset.initialOpen === '1') {
            this.open = true;
        }
        this.$watch('open', (v) => {
            if (v) {
                this.$nextTick(() => {
                    if (this.$refs.usernameInput) {
                        this.$refs.usernameInput.focus();
                    }
                });
            }
        });
    },
    flip() { this.open = !this.open; },
    get toggleLabel() {
        return this.open ? 'Hide local sign-in' : 'Use local sign-in';
    },
}));

// Categories edit page: live preview of the decay function.
Alpine.data('decayPreview', () => ({
    fn: 'exponential',
    param: 14,
    init() {
        const ds = this.$el.dataset;
        this.fn = ds.decayFn === 'linear' ? 'linear' : 'exponential';
        this.param = parseFloat(ds.decayParam) || 14;
    },
    decay(ageDays) {
        const p = Math.max(0.1, Number(this.param) || 0.1);
        if (this.fn === 'linear') {
            return Math.max(0, 1 - ageDays / p);
        }
        return Math.pow(0.5, ageDays / p);
    },
    path() {
        const x0 = 40, y0 = 10, w = 600, h = 200;
        const points = [];
        for (let i = 0; i <= 60; i++) {
            const x = x0 + (i / 60) * w;
            const y = y0 + (1 - this.decay(i)) * h;
            points.push(`${i === 0 ? 'M' : 'L'} ${x.toFixed(1)} ${y.toFixed(1)}`);
        }
        return points.join(' ');
    },
    get paramLabel() {
        return this.fn === 'linear' ? 'Days to zero' : 'Half-life days';
    },
}));

// Policies edit page: preview of the rendered blocklist for a policy.
Alpine.data('policyPreview', () => ({
    loading: true,
    count: 0,
    sample: [],
    policyId: 0,
    init() {
        this.policyId = parseInt(this.$el.dataset.policyId, 10) || 0;
        this.load();
    },
    async load() {
        this.loading = true;
        try {
            const res = await fetch('/app/policies/' + this.policyId + '/preview-proxy', { credentials: 'same-origin' });
            if (!res.ok) throw new Error('preview ' + res.status);
            const data = await res.json();
            this.count = data.count || 0;
            const items = Array.isArray(data.sample) ? data.sample : [];
            this.sample = items.map((raw, idx) => this.shapeEntry(raw, idx));
        } catch (e) {
            this.count = 0;
            this.sample = [{ key: 'err', label: '(preview unavailable)', expiry: '', tooltip: '' }];
        } finally {
            this.loading = false;
        }
    },
    get notLoading() { return !this.loading; },
    shapeEntry(raw, idx) {
        if (typeof raw === 'string') {
            return { key: 'r' + idx, label: raw, expiry: '', tooltip: '' };
        }
        const ip = raw.ip_or_cidr || '';
        let expiry = '';
        let tooltip = '';
        if (raw.expires_at) {
            const formatted = this.formatExpiry(raw.expires_at);
            expiry = (raw.expires_estimated ? '~ ' : '') + formatted;
            tooltip = (raw.expires_estimated
                ? 'Estimated falls-off date (assumes no further reports). ISO: '
                : 'Configured manual block expiry. ISO: ') + raw.expires_at;
        } else if (raw.reason === 'manual') {
            expiry = 'never';
            tooltip = 'Manual block has no configured expiry';
        } else {
            expiry = '—';
            tooltip = 'Score does not decay below threshold (threshold ≤ 0)';
        }
        return { key: 'r' + idx + ':' + ip, label: ip, expiry, tooltip };
    },
    formatExpiry(iso) {
        if (!iso) return '';
        const d = new Date(iso);
        if (isNaN(d.getTime())) return iso;
        try {
            return new Intl.DateTimeFormat(getDateLocales(), {
                year: 'numeric', month: '2-digit', day: '2-digit',
                hour: '2-digit', minute: '2-digit',
            }).format(d);
        } catch (_) { return iso; }
    },
}));

// Policies edit page: per-category histogram with shaded "blocked"
// regions to the right of each threshold.
Alpine.data('policyScoreDistribution', () => ({
    empty: false,
    chart: null,
    policyId: 0,
    init() {
        this.policyId = parseInt(this.$el.dataset.policyId, 10) || 0;
        this.load();
    },
    async load() {
        try {
            const res = await fetch('/app/policies/' + this.policyId + '/score-distribution-proxy', { credentials: 'same-origin' });
            if (!res.ok) throw new Error('distribution ' + res.status);
            const data = await res.json();
            this.render(data);
        } catch (e) {
            this.empty = true;
            if (this.chart) { this.chart.destroy(); this.chart = null; }
        }
    },
    render(data) {
        const CATEGORY_COLORS = [
            '#f87171', '#fbbf24', '#facc15', '#4ade80',
            '#38bdf8', '#a78bfa', '#f472b6', '#2dd4bf',
        ];
        const bucketSize = Number(data.bucket_size) || 5;
        const thresholds = Array.isArray(data.thresholds) ? data.thresholds : [];
        const categories = Array.isArray(data.categories) ? data.categories : [];
        const overallMaxScore = Number(data.overall_max_score) || Number(data.max_score) || 0;

        const thresholdByCat = {};
        const thresholdBySlug = {};
        for (const t of thresholds) {
            if (typeof t.threshold !== 'number') continue;
            if (t.category_id != null) thresholdByCat[t.category_id] = t.threshold;
            if (t.category_slug) thresholdBySlug[t.category_slug] = t.threshold;
        }

        const categoriesById = {};
        for (const c of categories) {
            if (c.category_id != null) categoriesById[c.category_id] = c;
        }

        const onChart = [];
        for (const t of thresholds) {
            if (typeof t.threshold !== 'number') continue;
            const cat = categoriesById[t.category_id];
            onChart.push({
                category_id: t.category_id,
                category_slug: t.category_slug || ('#' + t.category_id),
                threshold: t.threshold,
                buckets: cat && Array.isArray(cat.buckets) ? cat.buckets : [],
            });
        }
        this.empty = onChart.length === 0;

        let upperX = overallMaxScore;
        let yMax = 0;
        for (const oc of onChart) {
            if (oc.threshold > upperX) upperX = oc.threshold;
            for (const b of oc.buckets) {
                const start = Number(b.start) || 0;
                const cnt = Number(b.count) || 0;
                if (start + bucketSize > upperX) upperX = start + bucketSize;
                if (cnt > yMax) yMax = cnt;
            }
        }
        if (yMax <= 0) yMax = 1;
        if (upperX <= 0) upperX = bucketSize;
        upperX = upperX + bucketSize;

        const datasets = [];
        onChart.forEach((oc, i) => {
            const color = CATEGORY_COLORS[i % CATEGORY_COLORS.length];
            datasets.push({
                label: oc.category_slug + ' ≥ ' + oc.threshold,
                data: [
                    { x: oc.threshold, y: yMax },
                    { x: upperX, y: yMax },
                ],
                borderColor: hexToRgba(color, 0.45),
                backgroundColor: hexToRgba(color, 0.10),
                borderWidth: 1,
                pointRadius: 0,
                pointHoverRadius: 0,
                fill: 'origin',
                tension: 0,
                stepped: false,
                spanGaps: false,
                order: 5 + i,
                _isThresholdRegion: true,
            });

            const points = oc.buckets.map((b) => ({
                x: Number(b.start) || 0,
                y: Number(b.count) || 0,
            }));
            datasets.push({
                label: oc.category_slug,
                data: points,
                borderColor: color,
                backgroundColor: color,
                borderWidth: 2,
                tension: 0.25,
                fill: false,
                pointRadius: 3,
                pointHoverRadius: 5,
                pointBackgroundColor: color,
                order: i,
            });
        });

        const t = chartTheme();
        const canvas = this.$refs.canvas;
        if (!canvas) return;
        if (this.chart) {
            this.chart.destroy();
        }
        this.chart = new Chart(canvas, {
            type: 'line',
            data: { datasets },
            options: {
                responsive: true,
                maintainAspectRatio: false,
                parsing: false,
                plugins: {
                    legend: {
                        position: 'bottom',
                        labels: {
                            color: t.legendColor,
                            boxWidth: 12,
                            font: { size: 11 },
                            filter: (item, chartData) => {
                                const ds = chartData.datasets[item.datasetIndex];
                                return !ds || !ds._isThresholdRegion;
                            },
                        },
                    },
                    tooltip: {
                        filter: (item) => !item.dataset || !item.dataset._isThresholdRegion,
                        callbacks: {
                            title: (items) => {
                                if (!items.length) return '';
                                const it = items[0];
                                const start = it.parsed.x;
                                return it.dataset.label + ' — score ' + start + '–' + (start + bucketSize);
                            },
                            label: (it) => it.parsed.y + ' IP' + (it.parsed.y === 1 ? '' : 's'),
                        },
                    },
                },
                scales: {
                    x: {
                        type: 'linear',
                        min: 0,
                        max: upperX,
                        title: { display: true, text: 'score', color: t.tickColor },
                        ticks: { color: t.tickColor, stepSize: bucketSize },
                        grid: { color: t.gridColor },
                    },
                    y: {
                        beginAtZero: true,
                        title: { display: true, text: 'IPs', color: t.tickColor },
                        ticks: { color: t.tickColor, precision: 0 },
                        grid: { color: t.gridColor },
                    },
                },
            },
        });
    },
}));

// IP detail page: rolling decay-aware score plotted across week / month
// / all / future ranges. Reads its inputs from data attributes set by
// the template.
Alpine.data('scoreOverTime', () => ({
    range: 'month',
    ranges: [
        { id: 'week',   label: 'Week' },
        { id: 'month',  label: 'Month' },
        { id: 'all',    label: 'All' },
        { id: 'future', label: '+30d' },
    ],
    _now: 0,
    _reports: [],
    _categories: {},
    init() {
        const ds = this.$el.dataset;
        let payload = { reports: [], categories: [], now: '' };
        try {
            if (ds.scoreChart) {
                payload = JSON.parse(ds.scoreChart);
            }
        } catch (e) { /* fall through with defaults */ }
        const DAY_MS = 24 * 3600 * 1000;
        this._now = new Date(payload.now || Date.now()).getTime();
        this._reports = (payload.reports || [])
            .map(r => ({
                t: new Date(r.at).getTime(),
                category: r.category,
                weight: Number(r.weight) || 1,
            }))
            .filter(r => Number.isFinite(r.t));
        this._categories = (payload.categories || []).reduce((acc, c) => {
            if (c && c.slug) {
                acc[c.slug] = {
                    fn: c.decay_function === 'linear' ? 'linear' : 'exponential',
                    param: Math.max(0.1, Number(c.decay_param) || 14),
                };
            }
            return acc;
        }, {});
        this.DAY_MS = DAY_MS;
    },
    setRange(id) { this.range = id; },
    isRange(id) { return this.range === id; },
    isFuture() { return this.range === 'future'; },
    classForRange(id) {
        return this.range === id
            ? 'bg-indigo-600 text-white border-l border-slate-300 px-3 py-1 first:border-l-0 dark:border-slate-700'
            : 'bg-white text-slate-600 hover:bg-slate-50 dark:bg-slate-900 dark:text-slate-300 dark:hover:bg-slate-800 border-l border-slate-300 px-3 py-1 first:border-l-0 dark:border-slate-700';
    },
    decayFor(report, t) {
        const DEFAULT = { fn: 'exponential', param: 14 };
        const cat = this._categories[report.category] || DEFAULT;
        const ageDays = (t - report.t) / this.DAY_MS;
        if (ageDays < 0) return 0;
        if (cat.fn === 'linear') return Math.max(0, 1 - ageDays / cat.param);
        return Math.pow(0.5, ageDays / cat.param);
    },
    totalScoreAt(t) {
        let total = 0;
        for (const r of this._reports) total += r.weight * this.decayFor(r, t);
        return total;
    },
    fmtDate(ts) {
        const d = new Date(ts);
        if (isNaN(d.getTime())) return '';
        try {
            return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
        } catch (e) {
            return d.toISOString().slice(0, 10);
        }
    },
    get hasReports() { return this._reports.length > 0; },
    get hasNoReports() { return this._reports.length === 0; },
    get bounds() {
        const NOW = this._now;
        const DAY = this.DAY_MS;
        switch (this.range) {
            case 'week':   return { start: NOW - 7 * DAY,  end: NOW };
            case 'future': return { start: NOW,             end: NOW + 30 * DAY };
            case 'all': {
                const earliest = this._reports.length
                    ? Math.min.apply(null, this._reports.map(r => r.t))
                    : NOW - 30 * DAY;
                return { start: Math.min(earliest, NOW - DAY), end: NOW };
            }
            case 'month':
            default: return { start: NOW - 30 * DAY, end: NOW };
        }
    },
    get points() {
        const { start, end } = this.bounds;
        const N = 120;
        const span = end - start;
        const out = [];
        for (let i = 0; i <= N; i++) {
            const t = start + (span * i) / N;
            out.push({ t, v: this.totalScoreAt(t) });
        }
        return out;
    },
    get maxScoreDisplay() {
        let max = 0;
        for (const p of this.points) if (p.v > max) max = p.v;
        return max;
    },
    get maxScoreLabel() {
        return this.maxScoreDisplay.toFixed(2);
    },
    path() {
        const pts = this.points;
        if (pts.length === 0) return '';
        const x0 = 50, y0 = 20, w = 590, h = 180;
        const max = Math.max(this.maxScoreDisplay, 1e-6);
        const out = [];
        for (let i = 0; i < pts.length; i++) {
            const x = x0 + (i / (pts.length - 1)) * w;
            const y = y0 + (1 - pts[i].v / max) * h;
            out.push(`${i === 0 ? 'M' : 'L'} ${x.toFixed(1)} ${y.toFixed(1)}`);
        }
        return out.join(' ');
    },
    yLabel(frac) {
        return (this.maxScoreDisplay * frac).toFixed(2);
    },
    xLabel(frac) {
        const { start, end } = this.bounds;
        return this.fmtDate(start + (end - start) * frac);
    },
    xAxisCaption() {
        switch (this.range) {
            case 'week':   return 'last 7 days';
            case 'month':  return 'last 30 days';
            case 'all':    return 'all reported activity';
            case 'future': return 'next 30 days (forecast)';
            default:       return '';
        }
    },
    rangeLabel() {
        const opt = this.ranges.find(r => r.id === this.range);
        return opt ? opt.label : '';
    },
}));

// Tokens index page: copy-button for the just-issued raw token.
Alpine.data('rawTokenCopy', () => ({
    open: true,
    hide() { this.open = false; },
    copy() {
        const el = document.getElementById('raw-token');
        if (el) navigator.clipboard.writeText(el.value);
    },
}));

Alpine.start();
