/**
 * Demo Oracle — rapport ASH detaille : graphique empile et panneaux Top N.
 *
 * Lit sa configuration dans window.DemoOracleReport.ashDetail, injectee par la
 * vue. Depend de D3, charge par CDN avant ce fichier.
 *
 * Portage de modules/oracle/assets/js/report-ash-detail.js. Le decoupage en
 * tranches, le cache local et le filtrage croise sont conserves tels quels :
 * la demonstration plafonne la PERIODE, pas la finesse d'analyse a
 * l'interieur.
 *
 * Une seule difference de fond : l'encadre « pas de source ASH » n'affiche
 * plus la valeur de CONTROL_MANAGEMENT_PACK_ACCESS. C'est une information de
 * diagnostic, qui a sa place dans le test de connexion — reserve aux
 * administrateurs — et non dans un rapport ouvert au public.
 */
(function () {
'use strict';

var CFG = (window.DemoOracleReport || {}).ashDetail;
if (!CFG) return;

/* Les URL du module COMPLET etaient restees en dur dans ce fichier lors du
   portage — `/oracle/report/sql/detail`. Sur une installation ou seule la
   demonstration est deployee, elles rendent un 404 d'Apache. Le detail SQL
   passe donc par le routeur du module ; le detail d'objet, lui, n'existe pas
   ici et ne peut pas etre porte. */
var SQL_DETAIL_URL = CFG.sqlDetailUrl || '';
var FULL_ONLY      = CFG.fullOnly     || '';

/* ── Oracle ASH colours ──────────────────────────────────────────────────── */
var ASH_COLORS = {
    'CPU':           '#00BB00',
    'Scheduler':     '#78E078',
    'User I/O':      '#0043D0',
    'Other':         '#F071B0',
    'Concurrency':   '#801800',
    'Commit':        '#E06800',
    'System I/O':    '#0090E0',
    'Network':       '#989070',
    'Configuration': '#606810',
    'Application':   '#FF0000'
};
var CAT_ORDER = [
    'CPU', 'Scheduler', 'User I/O', 'System I/O',
    'Network', 'Commit', 'Concurrency', 'Configuration', 'Application', 'Other'
];
function ashColor(cat) { return ASH_COLORS[cat] || '#888'; }

/* ── 20-colour palette (Tableau 20) ─────────────────────────────────────── */
var PALETTE20 = [
    '#4e79a7','#a0cbe8','#f28e2b','#ffbe7d','#59a14f',
    '#8cd17d','#b6992d','#f1ce63','#499894','#86bcb6',
    '#e15759','#ff9d9a','#79706e','#bab0ac','#d37295',
    '#fabfd2','#b07aa1','#d4a6c8','#9d7660','#d7b5a6'
];

/* ── Dataset — populated asynchronously by _loadData() ──────────────────── */
var RAW      = [];
var ALL_CATS = [];
var yLabel   = CFG.yLabel;

/* ── Selector state (dropdowns) ─────────────────────────────────────────── */
var selectedDim      = 'category';     /* dimension for top-N display */
var filterService    = '';             /* '' = all services */
var filterWaitClass  = '';             /* '' = all wait classes */

/* i18n labels for dimension titles (injected from PHP) */
var DIM_LABELS       = CFG.dimLabels  || {};
var LBL_ALL_SERVICES = CFG.lblAllServices || '(all)';
var LBL_ALL_CLASSES  = CFG.lblAllClasses  || '(all)';

/* Config from PHP */
var DT_BEGIN     = CFG.dtBegin;
var DT_END       = CFG.dtEnd;
/* `hasSource`, pas `hasLicense` : la demonstration peut avoir une source sans
   aucune licence — sw$bash_snapper. La vue emploie ce vocabulaire depuis le
   portage ; ce fichier etait reste sur celui du module complet, si bien que
   CFG.hasLicense valait undefined et que _loadData() sortait aussitot. Aucune
   tranche n'etait demandee : la page restait vide, sans la moindre erreur. */
var HAS_SOURCE   = CFG.hasSource;
var BUCKET_SECS  = CFG.bucketSecs;
var DATA_SOURCE  = CFG.dataSource;
var ALL_BUCKETS  = CFG.allBuckets;

function _computeAllCats() {
    var seen = {};
    RAW.forEach(function (d) { seen[d.category] = true; });
    var ordered = CAT_ORDER.filter(function (c) { return seen[c]; });
    Object.keys(seen).forEach(function (c) {
        if (ordered.indexOf(c) < 0) ordered.push(c);
    });
    return ordered;
}

/* ── Filter state ────────────────────────────────────────────────────────── */
var activeCats     = null;   /* null = all; Set<string> = visible wait classes */
var brushRange     = null;   /* null = all; [Date, Date] = selected time window */
var activeSqls     = null;   /* null = all; Set<string> = selected SQL IDs */
var activePhvs     = null;   /* null = all; Set<string> = selected PHVs */
var activeFmss     = null;   /* null = all; Set<string> = selected FMS values */
var activeModules  = null;   /* null = all; Set<string> = selected Modules */
var activeActions  = null;   /* null = all; Set<string> = selected Actions */
var activeEvents   = null;   /* null = all; Set<string> = selected Wait Events */
var activeServices = null;   /* null = all; Set<string> = selected Services */
var activeMachines = null;   /* null = all; Set<string> = selected Machines */
var activeObjNames = null;   /* null = all; Set<string> = selected Object names */
var activeObjTypes = null;   /* null = all; Set<string> = selected Object types */
var activePEntries = null;   /* null = all; Set<string> = selected PL/SQL entries */
var activePObjects = null;   /* null = all; Set<string> = selected PL/SQL objects */

/* ── Helpers ─────────────────────────────────────────────────────────────── */
/* withBrush  : false → stacked-bar (no brush range applied)
                true  → top-N + total (brush range applied)
   excludeDim : 'sql'|'phv'|'fms'|'module'|'action'|'event'|null — each top-N
                chart excludes its own dimension so non-selected items stay visible */
function getFiltered(withBrush, excludeDim) {
    return RAW.filter(function (d) {
        /* Dropdown pre-filters (always applied) */
        if (filterService   && d.service_name !== filterService)  return false;
        if (filterWaitClass && d.category     !== filterWaitClass) return false;
        if (activeCats && !activeCats.has(d.category)) return false;
        if (withBrush && brushRange && (d.t < brushRange[0] || d.t >= brushRange[1])) return false;
        if (excludeDim !== 'sql'    && activeSqls    && !activeSqls.has(d.sql_id))      return false;
        if (excludeDim !== 'phv'    && activePhvs    && !activePhvs.has(d.phv))         return false;
        if (excludeDim !== 'fms'    && activeFmss    && !activeFmss.has(d.fms))         return false;
        if (excludeDim !== 'module'  && activeModules  && !activeModules.has(d.module))        return false;
        if (excludeDim !== 'action'  && activeActions  && !activeActions.has(d.action))        return false;
        if (excludeDim !== 'event'   && activeEvents   && !activeEvents.has(d.wait_event))     return false;
        if (excludeDim !== 'service' && activeServices && !activeServices.has(d.service_name)) return false;
        if (excludeDim !== 'machine' && activeMachines && !activeMachines.has(d.machine))      return false;
        if (excludeDim !== 'objname' && activeObjNames && !activeObjNames.has(d.object_name))  return false;
        if (excludeDim !== 'objtype' && activeObjTypes && !activeObjTypes.has(d.object_type))  return false;
        if (excludeDim !== 'pentry'  && activePEntries && !activePEntries.has(d.plsql_entry))  return false;
        if (excludeDim !== 'pobject' && activePObjects && !activePObjects.has(d.plsql_object)) return false;
        return true;
    });
}

function aggByMinuteCat(rows) {
    var map = {};
    rows.forEach(function (d) {
        var k = d.minute_bucket + '|' + d.category;
        if (!map[k]) map[k] = { minute_bucket: d.minute_bucket, t: d.t, category: d.category, cnt: 0 };
        map[k].cnt += d.cnt;
    });
    return Object.values(map);
}

/* Aggregate by minute × arbitrary dimension field (for timeline stacking) */
function aggByMinuteDim(rows, dimField) {
    var map = {};
    rows.forEach(function (d) {
        var label = d[dimField] || '(unknown)';
        var k = d.minute_bucket + '|' + label;
        if (!map[k]) map[k] = { minute_bucket: d.minute_bucket, t: d.t, category: label, cnt: 0 };
        map[k].cnt += d.cnt;
    });
    return Object.values(map);
}

/* Map dropdown value → data field name */
var DIM_TO_FIELD = {
    'sql_id':       'sql_id',
    'wait_event':   'wait_event',
    'module':       'module',
    'service_name': 'service_name',
    'machine':      'machine',
    'plsql_entry':  'plsql_entry',
    'object_name':  'object_name'
};

function aggTopN(rows, key, n) {
    var map = {};
    rows.forEach(function (d) {
        var lbl = d[key];
        if (!lbl || lbl === '0') return;
        if (!map[lbl]) map[lbl] = { label: lbl, cnt: 0 };
        map[lbl].cnt += d.cnt;
    });
    var sorted = Object.values(map).sort(function (a, b) { return b.cnt - a.cnt; });
    if (sorted.length <= n) return sorted;
    var top = sorted.slice(0, n);
    var otherCnt = sorted.slice(n).reduce(function (s, d) { return s + d.cnt; }, 0);
    top.push({ label: 'Other', cnt: otherCnt, isOther: true });
    return top;
}

/* ── Category filter toggle (right-click on legend) ─────────────────────── */
function toggleCat(cat) {
    if (!activeCats) {
        activeCats = new Set([cat]);
    } else if (activeCats.has(cat)) {
        activeCats.delete(cat);
        if (activeCats.size === 0) activeCats = null;
    } else {
        activeCats.add(cat);
        if (activeCats.size === ALL_CATS.length) activeCats = null;
    }
    redrawAll();
}

/* ── Dimension filter toggle for top-N bars (left-click) ────────────────── */
/* dim: 'sql'|'phv'|'fms'|'module'|'action'|'event'   val: label or null to reset */
var _dimGetters = {
    sql:     function ()  { return activeSqls; },
    phv:     function ()  { return activePhvs; },
    fms:     function ()  { return activeFmss; },
    module:  function ()  { return activeModules; },
    action:  function ()  { return activeActions; },
    event:   function ()  { return activeEvents; },
    service: function ()  { return activeServices; },
    machine: function ()  { return activeMachines; },
    objname: function ()  { return activeObjNames; },
    objtype: function ()  { return activeObjTypes; },
    pentry:  function ()  { return activePEntries; },
    pobject: function ()  { return activePObjects; }
};
var _dimSetters = {
    sql:     function (v) { activeSqls     = v; },
    phv:     function (v) { activePhvs     = v; },
    fms:     function (v) { activeFmss     = v; },
    module:  function (v) { activeModules  = v; },
    action:  function (v) { activeActions  = v; },
    event:   function (v) { activeEvents   = v; },
    service: function (v) { activeServices = v; },
    machine: function (v) { activeMachines = v; },
    objname: function (v) { activeObjNames = v; },
    objtype: function (v) { activeObjTypes = v; },
    pentry:  function (v) { activePEntries = v; },
    pobject: function (v) { activePObjects = v; }
};
function toggleDim(dim, val) {
    var getter = _dimGetters[dim];
    var setter = _dimSetters[dim];
    if (!getter || !setter) return;
    if (val === null) { setter(null); redrawAll(); return; }
    var cur = getter();
    if (!cur) {
        setter(new Set([val]));
    } else if (cur.has(val)) {
        cur.delete(val);
        setter(cur.size === 0 ? null : cur);
    } else {
        cur.add(val);
        setter(cur);
    }
    redrawAll();
}

/* ── Global reset button (top-N card header) ─────────────────────────────── */
function _anyFilterActive() {
    return activeCats || brushRange ||
           activeSqls || activePhvs || activeFmss ||
           activeModules || activeActions || activeEvents ||
           activeServices || activeMachines ||
           activeObjNames || activeObjTypes ||
           activePEntries || activePObjects;
}
function _resetAllFilters() {
    activeCats = null; brushRange = null;
    activeSqls = null; activePhvs = null; activeFmss = null;
    activeModules = null; activeActions = null; activeEvents = null;
    activeServices = null; activeMachines = null;
    activeObjNames = null; activeObjTypes = null;
    activePEntries = null; activePObjects = null;
}
function updateGlobalReset() {
    var el = document.getElementById('topn-global-reset');
    if (!el) return;
    el.innerHTML = '';
    if (!_anyFilterActive()) return;
    var btn = document.createElement('button');
    btn.textContent = '✕ reset tous les filtres';
    btn.style.cssText =
        'font-size:.75rem;padding:.15rem .5rem;cursor:pointer;'
        + 'border:1px solid currentColor;border-radius:3px;'
        + 'background:transparent;color:inherit;line-height:1.4';
    btn.addEventListener('click', function () { _resetAllFilters(); redrawAll(); });
    el.appendChild(btn);
}

/* Per-dimension reset link (in the chart title row) */
function updateDimReset(resetId, dim) {
    var el = document.getElementById(resetId);
    if (!el) return;
    el.innerHTML = '';
    var getter = _dimGetters[dim];
    var cur = getter ? getter() : null;
    if (!cur) return;
    var a = document.createElement('a');
    a.href = '#';
    a.textContent = '✕';
    a.title = 'Réinitialiser ce filtre';
    a.style.cssText = 'font-size:.75rem;color:inherit;opacity:.6;text-decoration:none';
    a.addEventListener('click', function (e) { e.preventDefault(); toggleDim(dim, null); });
    el.appendChild(a);
}

/* ── Master redraw ───────────────────────────────────────────────────────── */
function redrawAll() {
    var fBar   = getFiltered(false, null);
    var fTotal = getFiltered(true,  null);
    var total  = d3.sum(fTotal, function (d) { return d.cnt; });

    /* Timeline — stacked by selected dimension */
    var dimField = DIM_TO_FIELD[selectedDim] || null;
    if (dimField) {
        renderChart(aggByMinuteDim(fBar, dimField), dimField);
    } else {
        renderChart(aggByMinuteCat(fBar), null);
    }

    /* Top-N charts — always render all panels */
    var fSql = getFiltered(true, 'sql'), fPhv = getFiltered(true, 'phv'), fFms = getFiltered(true, 'fms');
    renderHBar('chart-top-sql', aggTopN(fSql, 'sql_id', 10), total, activeSqls,
               function (v) { toggleDim('sql', v); },
               function (id, x, y) { _showSqlCtxMenu(id, x, y); });
    renderHBar('chart-top-phv', aggTopN(fPhv, 'phv', 10), total, activePhvs,
               function (v) { toggleDim('phv', v); });
    renderHBar('chart-top-fms', aggTopN(fFms, 'fms', 10), total, activeFmss,
               function (v) { toggleDim('fms', v); });

    var fEvent = getFiltered(true, 'event');
    renderHBar('chart-top-event', aggTopN(fEvent, 'wait_event', 10), total, activeEvents,
               function (v) { toggleDim('event', v); });

    var fModule = getFiltered(true, 'module'), fAction = getFiltered(true, 'action');
    renderHBar('chart-top-module', aggTopN(fModule, 'module', 10), total, activeModules,
               function (v) { toggleDim('module', v); });
    renderHBar('chart-top-action', aggTopN(fAction, 'action', 10), total, activeActions,
               function (v) { toggleDim('action', v); });

    var fService = getFiltered(true, 'service');
    renderHBar('chart-top-service', aggTopN(fService, 'service_name', 10), total, activeServices,
               function (v) { toggleDim('service', v); });

    var fMachine = getFiltered(true, 'machine');
    renderHBar('chart-top-machine', aggTopN(fMachine, 'machine', 10), total, activeMachines,
               function (v) { toggleDim('machine', v); });

    var fObjName = getFiltered(true, 'objname'), fObjType = getFiltered(true, 'objtype');
    renderHBar('chart-top-objname', aggTopN(fObjName, 'object_name', 10), total, activeObjNames,
               function (v) { toggleDim('objname', v); },
               function (v, x, y) { _showObjCtxMenu(v, x, y); });
    renderHBar('chart-top-objtype', aggTopN(fObjType, 'object_type', 10), total, activeObjTypes,
               function (v) { toggleDim('objtype', v); });

    var fPEntry = getFiltered(true, 'pentry'), fPObject = getFiltered(true, 'pobject');
    renderHBar('chart-top-pentry', aggTopN(fPEntry, 'plsql_entry', 10), total, activePEntries,
               function (v) { toggleDim('pentry', v); });
    renderHBar('chart-top-pobject', aggTopN(fPObject, 'plsql_object', 10), total, activePObjects,
               function (v) { toggleDim('pobject', v); });

    updateDimReset('reset-sql',     'sql');
    updateDimReset('reset-phv',     'phv');
    updateDimReset('reset-fms',     'fms');
    updateDimReset('reset-module',  'module');
    updateDimReset('reset-action',  'action');
    updateDimReset('reset-event',   'event');
    updateDimReset('reset-service', 'service');
    updateDimReset('reset-machine', 'machine');
    updateDimReset('reset-objname', 'objname');
    updateDimReset('reset-objtype', 'objtype');
    updateDimReset('reset-pentry',  'pentry');
    updateDimReset('reset-pobject', 'pobject');
    updateGlobalReset();
}

/* ── Stacked bar chart ───────────────────────────────────────────────────── */
function renderChart(barData, dimField) {
    var container = document.getElementById('chart-ash-detail');
    var legendCt  = document.getElementById('chart-ash-legend');
    if (!container) return;
    container.innerHTML = '';
    if (legendCt) legendCt.innerHTML = '';
    if (!barData.length) return;

    var isWaitClass = !dimField;
    var present, colorFn;
    if (isWaitClass) {
        present = ALL_CATS.filter(function (c) {
            return barData.some(function (d) { return d.category === c; });
        });
        colorFn = ashColor;
    } else {
        var countMap = {};
        barData.forEach(function (d) {
            countMap[d.category] = (countMap[d.category] || 0) + d.cnt;
        });
        present = Object.keys(countMap).sort(function (a, b) { return countMap[b] - countMap[a]; });
        if (present.length > 12) {
            var top12 = present.slice(0, 12);
            var others = present.slice(12);
            barData = barData.map(function (d) {
                if (others.indexOf(d.category) >= 0) {
                    return { minute_bucket: d.minute_bucket, t: d.t, category: '(other)', cnt: d.cnt };
                }
                return d;
            });
            present = top12.concat(['(other)']);
        }
        var dimColorMap = {};
        present.forEach(function (c, i) { dimColorMap[c] = PALETTE20[i % PALETTE20.length]; });
        colorFn = function (c) { return dimColorMap[c] || '#888'; };
    }

    /* Liste complète des abscisses (generee cote PHP avec ancrage epoch) :
       on affiche TOUS les buckets de la periode, meme les vides. */
    var buckets = ALL_BUCKETS.map(function (s) {
        return new Date(s.replace(' ', 'T'));
    });

    /* Index des donnees par timestamp pour un pivot O(N+M) au lieu de O(NxM) */
    var dataByTs = {};
    barData.forEach(function (d) {
        var k = d.t.getTime();
        if (!dataByTs[k]) dataByTs[k] = [];
        dataByTs[k].push(d);
    });

    /* Pivot to wide format — un row par bucket meme si pas de donnees */
    var pivoted = buckets.map(function (t) {
        var row = { t: t };
        present.forEach(function (c) { row[c] = 0; });
        var ds = dataByTs[t.getTime()];
        if (ds) ds.forEach(function (d) { row[d.category] = (row[d.category] || 0) + d.cnt; });
        return row;
    });

    /* Stack */
    var stacked = d3.stack().keys(present)(pivoted);

    /* Layout — intMs reflete la taille du bucket actuel (10s, 1min, 10min, 1h).
       W est fixe a la largeur du conteneur : la largeur des barres (barW)
       se calcule a partir du ratio innerW x intMs / domainMs et descend
       jusqu'a 2 px quand les buckets sont trop nombreux pour la page. */
    var intMs  = BUCKET_SECS * 1000;
    var margin = { top: 10, right: 12, bottom: 38, left: 46 };
    var W      = container.clientWidth || 800;
    var H      = 180;
    var innerW = W - margin.left - margin.right;
    var innerH = H - margin.top - margin.bottom;

    var tMin   = new Date(buckets[0].getTime() - intMs / 2);
    var tMax   = new Date(buckets[buckets.length - 1].getTime() + intMs / 2);
    var xScale = d3.scaleTime().domain([tMin, tMax]).range([0, innerW]);
    var yTop   = d3.max(stacked, function (l) { return d3.max(l, function (d) { return d[1]; }); }) || 1;
    var yScale = d3.scaleLinear().domain([0, yTop]).nice().range([innerH, 0]);
    var barW   = Math.max(2, Math.floor(innerW * intMs / (tMax - tMin)) - 1);

    /* SVG */
    var svg = d3.create('svg').attr('width', W).attr('height', H)
        .style('font-family', 'inherit').style('font-size', '11px');
    var g = svg.append('g')
        .attr('transform', 'translate(' + margin.left + ',' + margin.top + ')');

    /* Grid */
    g.append('g')
        .call(d3.axisLeft(yScale).tickSize(-innerW).tickFormat(''))
        .call(function (s) {
            s.select('.domain').remove();
            s.selectAll('line').attr('stroke', '#ccc').attr('stroke-dasharray', '3,3');
        });

    /* Stacked bars */
    stacked.forEach(function (layer) {
        g.append('g').attr('fill', colorFn(layer.key))
            .selectAll('rect').data(layer).join('rect')
            .attr('x',      function (d) { return xScale(d.data.t) - barW / 2; })
            .attr('y',      function (d) { return yScale(d[1]); })
            .attr('height', function (d) { return Math.max(0, yScale(d[0]) - yScale(d[1])); })
            .attr('width',  barW)
            .append('title')
            .text(function (d) { return layer.key + ' : ' + d.data[layer.key] + ' sessions'; });
    });

    /* X axis — format adaptatif :
       <  1 j → %H:%M  (heure:min)
       >= 1 j → %d/%m %H:%M  (jour + heure:min, sinon ambiguite sur multi-jours) */
    var spanMs    = tMax - tMin;
    var fmtTick   = (spanMs < 86400000)
                  ? d3.timeFormat('%H:%M')
                  : d3.timeFormat('%d/%m %H:%M');
    g.append('g')
        .attr('transform', 'translate(0,' + innerH + ')')
        .call(d3.axisBottom(xScale).tickFormat(fmtTick).ticks(8))
        .call(function (s) {
            s.selectAll('text')
                .attr('transform', 'rotate(-45)')
                .style('text-anchor', 'end')
                .attr('dx', '-.5em').attr('dy', '.15em');
        });

    /* Y axis */
    g.append('g').call(d3.axisLeft(yScale).ticks(6));

    /* Y label */
    g.append('text')
        .attr('transform', 'rotate(-90)')
        .attr('x', -innerH / 2).attr('y', -margin.left + 14)
        .attr('text-anchor', 'middle').style('font-size', '10px').attr('fill', 'currentColor')
        .text(yLabel);

    /* D3 brushX — full height, selects a time window.
       _brushMoving guards against brush.move() re-triggering "end". */
    var _brushMoving = false;
    var brush = d3.brushX()
        .extent([[0, 0], [innerW, innerH]])
        .on('end', function (event) {
            if (_brushMoving) return;
            brushRange = event.selection
                ? [ xScale.invert(event.selection[0]),
                    xScale.invert(event.selection[1]) ]
                : null;
            redrawAll();
        });

    var brushG = g.append('g').attr('class', 'brush').call(brush);

    /* Restore brush visual position after redraw (does NOT re-filter) */
    if (brushRange) {
        _brushMoving = true;
        brushG.call(brush.move, [xScale(brushRange[0]), xScale(brushRange[1])]);
        _brushMoving = false;
    }

    /* Legend */
    var legendEl = document.createElement('div');
    legendEl.style.cssText =
        'display:flex;flex-wrap:nowrap;align-items:center;gap:.15rem .5rem;'
        + 'font-size:.75rem;user-select:none;white-space:nowrap;overflow-x:auto';

    var legendItems = isWaitClass ? ALL_CATS : present;
    legendItems.forEach(function (cat) {
        var isActive = isWaitClass ? (!activeCats || activeCats.has(cat)) : true;
        var item = document.createElement('span');
        item.style.cssText =
            'display:inline-flex;align-items:center;gap:.3rem;'
            + (isWaitClass ? 'cursor:context-menu;' : '')
            + 'opacity:' + (isActive ? '1' : '0.25') + ';transition:opacity .15s';
        if (isWaitClass) item.title = 'Clic droit : isoler / ajouter au filtre';
        var sw = document.createElement('span');
        sw.style.cssText =
            'display:inline-block;width:12px;height:12px;border-radius:2px;'
            + 'flex-shrink:0;background:' + colorFn(cat);
        item.appendChild(sw);
        item.appendChild(document.createTextNode(cat));
        if (isWaitClass) {
            item.addEventListener('contextmenu', function (e) {
                e.preventDefault();
                toggleCat(cat);
            });
        }
        legendEl.appendChild(item);
    });

    /* Reset button — visible when any filter is active */
    if (_anyFilterActive()) {
        var btn = document.createElement('button');
        btn.textContent = '✕ reset';
        btn.style.cssText =
            'margin-left:.6rem;font-size:.75rem;padding:.1rem .45rem;cursor:pointer;'
            + 'border:1px solid currentColor;border-radius:3px;'
            + 'background:transparent;color:inherit;line-height:1.4';
        btn.addEventListener('click', function () { _resetAllFilters(); redrawAll(); });
        legendEl.appendChild(btn);
    }

    if (legendCt) legendCt.appendChild(legendEl);
    container.appendChild(svg.node());
}

/* ── Theme-aware text style for SVG bar labels ───────────────────────────── */
/* Probes the actual computed color of the page so currentColor is never used
   directly — avoids white-on-white in themes where the inherited SVG color
   happens to be light even when the background is also light.               */
function _textStyle() {
    var probe = document.querySelector('.card') || document.body;
    var color = getComputedStyle(probe).color;          /* e.g. "rgb(34, 34, 34)" */
    var m     = color.match(/\d+/g);
    if (m && m.length >= 3) {
        /* Relative luminance (ITU-R BT.601) — > 0.5 means light text */
        var lum = (0.299 * +m[0] + 0.587 * +m[1] + 0.114 * +m[2]) / 255;
        return {
            fill:   color,
            stroke: lum > 0.5 ? 'rgba(0,0,0,0.75)' : 'rgba(255,255,255,0.9)'
        };
    }
    return { fill: '#222', stroke: 'rgba(255,255,255,0.9)' };
}

/* ── Horizontal bar chart ────────────────────────────────────────────────── */
/* activeSet : Set<string> | null — which labels are selected (null = all)   */
/* onToggle  : function(label) — called on left-click (null to reset)        */
function renderHBar(containerId, data, total, activeSet, onToggle, onRightClick) {
    var container = document.getElementById(containerId);
    if (!container) return;
    container.innerHTML = '';
    if (!data.length) return;

    /* maxCnt from top-10 only (Other can exceed #1 if many small items) */
    var maxCnt = d3.max(data.filter(function (d) { return !d.isOther; }),
                        function (d) { return d.cnt; }) || 1;
    var barH   = 14;
    var gap    = 2;
    var margin = { top: 2, right: 6, bottom: 2, left: 2 };
    var W      = Math.max(container.clientWidth || 0, 260);
    var innerW = Math.max(W - margin.left - margin.right, 60);
    var H      = data.length * (barH + gap) + margin.top + margin.bottom;

    var xScale = d3.scaleLinear().domain([0, maxCnt]).range([0, innerW]);
    var ts     = _textStyle();   /* theme-aware fill + halo stroke */

    var svg = d3.create('svg').attr('width', W).attr('height', H)
        .style('font-family', 'inherit').style('font-size', '10px');
    var g = svg.append('g')
        .attr('transform', 'translate(' + margin.left + ',' + margin.top + ')');

    data.forEach(function (d, i) {
        var isOther  = !!d.isOther;
        var y        = i * (barH + gap);
        /* cap bar width at innerW so "Other" never overflows */
        var bw       = Math.min(innerW, Math.max(2, xScale(d.cnt)));
        var pct      = total > 0 ? (d.cnt / total * 100).toFixed(1) : '0.0';
        var color    = isOther ? '#aaaaaa' : PALETTE20[i % PALETTE20.length];
        var isActive = isOther || !activeSet || activeSet.has(d.label);
        var opacity  = !isOther && activeSet && !activeSet.has(d.label) ? 0.22 : 1;

        var row = g.append('g')
            .style('cursor', isOther ? 'default' : 'pointer')
            .style('opacity', opacity);
        if (!isOther) {
            row.on('click', function () { if (onToggle) onToggle(d.label); });
            if (onRightClick) {
                row.on('contextmenu', function (event) {
                    event.preventDefault();
                    onRightClick(d.label, event.clientX, event.clientY);
                });
            }
        }

        row.append('rect')
            .attr('x', 0).attr('y', y)
            .attr('width', bw).attr('height', barH)
            .attr('fill', color).attr('rx', 2)
            .attr('stroke', !isOther && isActive && activeSet ? color : 'none')
            .attr('stroke-width', 2);

        row.append('text')
            .attr('x', 4).attr('y', y + barH * 0.76)
            .attr('fill', ts.fill)
            .attr('stroke', ts.stroke)
            .attr('stroke-width', 2)
            .attr('stroke-linejoin', 'round')
            .style('paint-order', 'stroke fill')
            .style('font-weight', isOther ? '400' : '600')
            .text(d.label + ' - (' + pct + ' %)');
    });

    container.appendChild(svg.node());
}

/* ── SQL detail context menu ─────────────────────────────────────────────── */
var _ctxMenu = null;

function _hideCtxMenu() {
    if (_ctxMenu) { _ctxMenu.remove(); _ctxMenu = null; }
    document.removeEventListener('click', _hideCtxMenu);
    document.removeEventListener('contextmenu', _hideCtxMenu);
}

function _showSqlCtxMenu(sqlId, x, y) {
    _hideCtxMenu();
    if (!sqlId || !/^[0-9a-z]{13}$/i.test(sqlId)) return;

    var menu = document.createElement('div');
    menu.style.cssText =
        'position:fixed;z-index:9999;'
        + 'background:var(--color-card-bg,#fff);'
        + 'border:1px solid var(--color-border,#ddd);border-radius:4px;'
        + 'box-shadow:0 2px 10px rgba(0,0,0,.18);padding:.2rem 0;min-width:180px;'
        + 'left:' + Math.min(x, window.innerWidth  - 200) + 'px;'
        + 'top:'  + Math.min(y, window.innerHeight - 80)  + 'px';

    function _item(label, action) {
        var li = document.createElement('div');
        li.textContent = label;
        li.style.cssText =
            'padding:.38rem .8rem;cursor:pointer;font-size:.84rem;'
            + 'white-space:nowrap;user-select:none';
        li.addEventListener('mouseenter', function () {
            li.style.background = 'var(--color-primary,#4e79a7)';
            li.style.color = '#fff';
        });
        li.addEventListener('mouseleave', function () {
            li.style.background = ''; li.style.color = '';
        });
        li.addEventListener('click', function () { _hideCtxMenu(); action(); });
        menu.appendChild(li);
    }

    _item('Ouvrir le détail SQL', function () {
        window.open(SQL_DETAIL_URL + '&sql_id=' + encodeURIComponent(sqlId), '_blank');
    });
    _item('Copier le SQL_ID', function () {
        if (navigator.clipboard) {
            navigator.clipboard.writeText(sqlId).catch(function () {});
        } else {
            var ta = document.createElement('textarea');
            ta.value = sqlId; ta.style.cssText = 'position:fixed;opacity:0';
            document.body.appendChild(ta); ta.select();
            try { document.execCommand('copy'); } catch (e) {}
            document.body.removeChild(ta);
        }
    });

    document.body.appendChild(menu);
    _ctxMenu = menu;
    setTimeout(function () {
        document.addEventListener('click',       _hideCtxMenu, { once: true });
        document.addEventListener('contextmenu', _hideCtxMenu, { once: true });
    }, 0);
}

/* ── Object context menu ─────────────────────────────────────────────────
   Right-click on a bar of chart-top-objname -> opens the object detail
   report in a new tab. ASH does not include the owner (aggregation by
   object_name only) — the report resolves ambiguity server-side. */
function _showObjCtxMenu(objName, x, y) {
    _hideCtxMenu();
    if (!objName) return;

    var menu = document.createElement('div');
    menu.style.cssText =
        'position:fixed;z-index:9999;'
        + 'background:var(--color-card-bg,#fff);'
        + 'border:1px solid var(--color-border,#ddd);border-radius:4px;'
        + 'box-shadow:0 2px 10px rgba(0,0,0,.18);padding:.2rem 0;min-width:200px;'
        + 'left:' + Math.min(x, window.innerWidth  - 220) + 'px;'
        + 'top:'  + Math.min(y, window.innerHeight - 80)  + 'px';

    function _item(label, action) {
        var li = document.createElement('div');
        li.textContent = label;
        li.style.cssText =
            'padding:.38rem .8rem;cursor:pointer;font-size:.84rem;'
            + 'white-space:nowrap;user-select:none';
        li.addEventListener('mouseenter', function () {
            li.style.background = 'var(--color-primary,#4e79a7)';
            li.style.color = '#fff';
        });
        li.addEventListener('mouseleave', function () {
            li.style.background = ''; li.style.color = '';
        });
        li.addEventListener('click', function () { _hideCtxMenu(); action(); });
        menu.appendChild(li);
    }

    /* Entree inerte, marquee « version complete ». Pas de gestionnaire de
       clic du tout : desactiver visuellement en laissant l'action derriere
       ne protegerait rien, et c'est la regle du module. */
    function _itemLocked(label) {
        var li = document.createElement('div');
        li.textContent = label + ' \uD83D\uDD12';
        li.title = FULL_ONLY;
        li.style.cssText =
            'padding:.38rem .8rem;cursor:default;font-size:.84rem;'
            + 'white-space:nowrap;user-select:none;opacity:.55';
        menu.appendChild(li);
    }

    /* La demonstration n'a pas de page « detail d'objet » : la section
       object/detail est absente de sa carte. L'entree reste affichee, mais
       inerte et marquee — meme regle que partout ailleurs dans le module :
       ce qui manque est signale, pas dissimule. */
    _itemLocked('Détail de l\'objet');
    _item('Copier le nom', function () {
        if (navigator.clipboard) {
            navigator.clipboard.writeText(objName).catch(function () {});
        }
    });

    document.body.appendChild(menu);
    _ctxMenu = menu;
    setTimeout(function () {
        document.addEventListener('click',       _hideCtxMenu, { once: true });
        document.addEventListener('contextmenu', _hideCtxMenu, { once: true });
    }, 0);
}

/* ── Populate dropdown selectors from loaded data ───────────────────────── */
function _populateDropdowns() {
    /* Service dropdown */
    var svcSel = document.getElementById('ash-svc-filter');
    if (svcSel) {
        var services = {};
        RAW.forEach(function (d) { if (d.service_name) services[d.service_name] = true; });
        var svcList = Object.keys(services).sort();
        svcSel.innerHTML = '';
        var optAll = document.createElement('option');
        optAll.value = ''; optAll.textContent = LBL_ALL_SERVICES;
        svcSel.appendChild(optAll);
        svcList.forEach(function (s) {
            var opt = document.createElement('option');
            opt.value = s; opt.textContent = s;
            if (s === filterService) opt.selected = true;
            svcSel.appendChild(opt);
        });
    }

    /* Wait class dropdown */
    var wcSel = document.getElementById('ash-wc-filter');
    if (wcSel) {
        var prevVal = filterWaitClass;
        wcSel.innerHTML = '';
        var optAll2 = document.createElement('option');
        optAll2.value = ''; optAll2.textContent = LBL_ALL_CLASSES;
        wcSel.appendChild(optAll2);
        ALL_CATS.forEach(function (c) {
            var opt = document.createElement('option');
            opt.value = c; opt.textContent = c;
            if (c === prevVal) opt.selected = true;
            wcSel.appendChild(opt);
        });
    }
}

/* ── Dropdown change handlers ───────────────────────────────────────────── */
function _bindDropdowns() {
    var dimSel = document.getElementById('ash-dim-select');
    var svcSel = document.getElementById('ash-svc-filter');
    var wcSel  = document.getElementById('ash-wc-filter');

    if (dimSel) dimSel.addEventListener('change', function () {
        selectedDim = this.value;
        redrawAll();
    });
    if (svcSel) svcSel.addEventListener('change', function () {
        filterService = this.value;
        redrawAll();
    });
    if (wcSel) wcSel.addEventListener('change', function () {
        filterWaitClass = this.value;
        /* When a wait class is selected via dropdown, clear the legend-based
           activeCats filter to avoid contradictions */
        activeCats = null;
        redrawAll();
    });
}

/* ── Async data loading ──────────────────────────────────────────────────── */

function _pad2(n) { return n < 10 ? '0' + n : '' + n; }
function _fmtDt(d) {
    return d.getFullYear() + '-' + _pad2(d.getMonth() + 1) + '-' + _pad2(d.getDate())
         + ' ' + _pad2(d.getHours()) + ':' + _pad2(d.getMinutes());
}

/* Split [begin, end] into <= maxH-hour chunks */
function _splitChunks(begin, end, maxH) {
    var chunks = [], cur = new Date(begin.replace(' ', 'T')),
        endD = new Date(end.replace(' ', 'T'));
    while (cur < endD) {
        var next = new Date(cur.getTime() + maxH * 3600000);
        if (next > endD) next = endD;
        chunks.push({ begin: _fmtDt(cur), end: _fmtDt(next) });
        cur = next;
    }
    return chunks;
}

/* localStorage cache — silently ignores quota / private-mode errors.
   La cle inclut BUCKET_SECS : une meme tranche cachee a 1 min ne doit
   pas etre reutilisee par une requete a 10 min. */
function _cacheKey(begin, end) {
    var db = new URL(window.location.href).searchParams.get('db') || '';
    /* v5 : jusqu'a la v4, une tranche vide etait mise en cache une heure — y
       compris quand le vide venait d'une requete en echec, silencieusement
       reduite a un tableau vide cote serveur. Le serveur remonte desormais
       l'erreur, mais les entrees deja empoisonnees survivraient : changer le
       prefixe les invalide toutes. */
    return 'ash_v5_' + db + '_b' + BUCKET_SECS + '_' + begin + '_' + end;
}
function _cacheTtl(end) {
    var age = Date.now() - new Date(end.replace(' ', 'T')).getTime();
    return age > 600000 ? 3600000 : 180000; /* >10 min old -> 1 h; recent -> 3 min */
}
function _cacheGet(key) {
    try {
        var s = localStorage.getItem(key);
        if (!s) return null;
        var o = JSON.parse(s);
        if (Date.now() > o.exp) { localStorage.removeItem(key); return null; }
        return o.rows;
    } catch (e) { return null; }
}
function _cacheSet(key, rows, ttl) {
    try { localStorage.setItem(key, JSON.stringify({ rows: rows, exp: Date.now() + ttl })); }
    catch (e) { /* quota or private mode */ }
}

/* Build AJAX URL for one chunk (clones current URL, swaps relevant params).
   bucket_secs est calcule sur la periode complete et propage a chaque chunk
   pour garantir l'alignement des frontieres de buckets entre tranches. */
function _chunkUrl(begin, end) {
    var u = new URL(window.location.href);
    u.searchParams.set('format',      'json');
    u.searchParams.set('chunk_begin', begin);
    u.searchParams.set('chunk_end',   end);
    u.searchParams.set('bucket_secs', BUCKET_SECS);
    u.searchParams.delete('dt_begin');
    u.searchParams.delete('dt_end');
    return u.toString();
}

/* Fetch one chunk: cache-first, then network */
function _fetchChunk(begin, end) {
    var key = _cacheKey(begin, end), cached = _cacheGet(key);
    if (cached) return Promise.resolve(cached);
    return fetch(_chunkUrl(begin, end))
        .then(function (r) { if (!r.ok) throw new Error('HTTP ' + r.status); return r.json(); })
        .then(function (data) {
            if (data.license === false) {
                throw new Error('NO_LICENSE');
            }
            if (data.error) throw new Error(data.error);
            var rows = data.rows || [];
            _cacheSet(key, rows, _cacheTtl(end));
            return rows;
        });
}

/* Attach Date + numeric cnt to raw rows */
function _transform(rows) {
    rows.forEach(function (d) {
        d.t   = new Date(d.minute_bucket.replace(' ', 'T'));
        d.cnt = +d.cnt;
    });
    return rows;
}

/* Progress bar in #chart-ash-detail while data is loading */
function _showProgress(loaded, total) {
    var el = document.getElementById('chart-ash-detail');
    if (!el) return;
    var pct = total ? Math.round(loaded / total * 100) : 0;
    el.innerHTML =
        '<div style="padding:.4rem 0">'
        + '<span style="font-size:.8rem;opacity:.65">' + loaded + ' / ' + total + ' tranches…</span>'
        + '<div style="margin-top:.3rem;height:4px;border-radius:2px;'
        +      'background:var(--color-border,#ddd)">'
        + '<div style="height:4px;border-radius:2px;transition:width .2s;'
        +      'background:var(--color-primary,#4e79a7);width:' + pct + '%"></div>'
        + '</div></div>';
}

function _loadData() {
    if (!HAS_SOURCE) return;
    var chunks = _splitChunks(DT_BEGIN, DT_END, 48);   // tranches de 48 h
    var total  = chunks.length, loaded = 0;
    _showProgress(0, total);

    var promises = chunks.map(function (c) {
        return _fetchChunk(c.begin, c.end).then(function (rows) {
            loaded++;
            _showProgress(loaded, total);
            return _transform(rows);
        });
    });

    Promise.all(promises)
        .then(function (results) {
            RAW = [];
            results.forEach(function (rows) { rows.forEach(function (r) { RAW.push(r); }); });
            ALL_CATS = _computeAllCats();
            _populateDropdowns();
            redrawAll();
        })
        .catch(function (err) {
            var el = document.getElementById('chart-ash-detail');
            if (!el) return;
            if (err.message === 'NO_LICENSE') {
                // La demonstration n'affiche pas la valeur du parametre
                // d'instance : c'est une information de diagnostic, elle a sa
                // place dans le test de connexion, pas dans un rapport public.
                el.innerHTML = '<div class="alert alert-warning" style="margin:0">'
                    + CFG.noLicenseMsg + '</div>';
            } else {
                var msg = String(err.message).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
                el.innerHTML = '<p style="color:red;padding:.4rem">Erreur de chargement : ' + msg + '</p>';
            }
        });
}

/* ── Boot ────────────────────────────────────────────────────────────────── */
_bindDropdowns();
_loadData();

var _rt;
window.addEventListener('resize', function () {
    clearTimeout(_rt);
    _rt = setTimeout(redrawAll, 220);
});

}());
