Source: ui/debugPanel.js

import {ddValue, esc, isTouchDevice} from "../global/utils";
import {
    ADAPTIVE_QUALITY_MIN,
    ADAPTIVE_QUALITY_THRESHOLD_HIGH,
    ADAPTIVE_QUALITY_THRESHOLD_LOW,
    log,
    LOG_LEVEL
} from "../global/constants";
import {getFractalMode, isAnimationActive, isJuliaMode} from "./ui";

/**
 * Debug Panel
 *
 * @author Radim Brnka
 * @description Provides an on-screen debugging panel for monitoring real-time application performance,
 * GPU capabilities, and rendering diagnostics related to WebGL fractal rendering.
 * @copyright Synaptory Fractal Traveler, 2025-2026
 * @license MIT
 */
export class DebugPanel {

    panelSelector = 'debugInfo';

    constructor(canvas, fractalApp) {
        this.canvas = canvas;
        this.fractalApp = fractalApp;
        this.gl = fractalApp?.gl;

        this.debugInfo = document.getElementById(this.panelSelector);
        if (!this.debugInfo) {
            console.warn(`DebugPanel: #${this.panelSelector} element not found.`);
            return;
        }

        if (isTouchDevice()) {
            let lastTap = 0;
            this.debugInfo.addEventListener("pointerdown", (e) => {
                const now = Date.now();
                if (now - lastTap < 300) { // Double tap detected
                    this.toggle();
                }
                lastTap = now;
                e.preventDefault();
            });
        }

        this.debugInfo.addEventListener("auxclick", (event) => {
            if (event.button === 1) {
                console.group('> DEBUG PANEL DUMP');
                log(this.debugInfo.innerText, this.constructor.name, LOG_LEVEL.DEBUG);
                console.groupEnd();

                let dump = this.debugInfo.innerText.substring(this.debugInfo.innerText.indexOf('FRAG'));

                navigator.clipboard.writeText(dump).then(function () {
                    log('Debug dump copied to clipboard!');
                }, function (err) {
                    log('Debug dump not copied to clipboard! ' + err.toString(), "", LOG_LEVEL.ERROR);
                });
            }
        });

        // ---- Extensions / GPU info (cached) ----
        this.extTimer =
            this.gl?.getExtension("EXT_disjoint_timer_query") ||
            this.gl?.getExtension("EXT_disjoint_timer_query_webgl2") ||
            null;

        // Renderer info (optional, blocked in some browsers unless allowed)
        this.extRendererInfo =
            this.gl?.getExtension("WEBGL_debug_renderer_info") || null;

        this.gpu = {
            vendor: "unknown",
            renderer: "unknown",
            unmaskedVendor: null,
            unmaskedRenderer: null,
            webglVersion: "unknown",
            shadingLanguageVersion: "unknown",
            maxTextureSize: null,
            maxVaryings: null,
            maxFragUniforms: null,
            maxVertUniforms: null,
            maxViewportDims: null,
        };

        this._initGpuInfo();

        // ---- Persistent perf state ----
        this.perf = {
            // rAF timing (debug panel update rate - typically monitor refresh rate)
            lastRafTs: performance.now(),
            fps: 0,
            frameMs: 0,
            frameMsSmoothed: NaN,

            // Actual render FPS (how often draw() is called)
            drawCount: 0,
            lastDrawCountReset: performance.now(),
            renderFps: 0,

            // CPU timing of this panel update itself (not your fractal draw)
            panelCpuMs: 0,
            panelCpuMsSmoothed: NaN,

            // GPU timing via timer query (requires beginGpuTimer/endGpuTimer around draw)
            gpuSupported: !!this.extTimer,
            gpuMs: NaN,
            gpuMsSmoothed: NaN,
            gpuDisjoint: false,
            gpuLastUpdateTs: 0,  // timestamp of last GPU measurement

            // Timer query bookkeeping
            queryInFlight: null,
            pendingQueries: [],
        };

        // Toggle visibility on creation
        this.toggle();

        // Bind-safe update loop
        requestAnimationFrame(this.update);
    }

    setRenderer(renderer) {
        this.fractalApp = renderer || null;
        this.gl = renderer?.gl || null;
    }

    _initGpuInfo() {
        const gl = this.gl;
        if (!gl) return;

        try {
            this.gpu.webglVersion = gl.getParameter(gl.VERSION) || "unknown";
            this.gpu.shadingLanguageVersion =
                gl.getParameter(gl.SHADING_LANGUAGE_VERSION) || "unknown";
            this.gpu.vendor = gl.getParameter(gl.VENDOR) || "unknown";
            this.gpu.renderer = gl.getParameter(gl.RENDERER) || "unknown";

            this.gpu.maxTextureSize = gl.getParameter(gl.MAX_TEXTURE_SIZE);
            this.gpu.maxVaryings = gl.getParameter(gl.MAX_VARYING_VECTORS);
            this.gpu.maxFragUniforms = gl.getParameter(gl.MAX_FRAGMENT_UNIFORM_VECTORS);
            this.gpu.maxVertUniforms = gl.getParameter(gl.MAX_VERTEX_UNIFORM_VECTORS);
            this.gpu.maxViewportDims = gl.getParameter(gl.MAX_VIEWPORT_DIMS);

            if (this.extRendererInfo) {
                const v = gl.getParameter(
                    this.extRendererInfo.UNMASKED_VENDOR_WEBGL
                );
                const r = gl.getParameter(
                    this.extRendererInfo.UNMASKED_RENDERER_WEBGL
                );
                this.gpu.unmaskedVendor = v || null;
                this.gpu.unmaskedRenderer = r || null;
            }
        } catch (e) {
            // Some browsers throw on blocked params; keep defaults
            console.warn("DebugPanel: GPU info query failed:", e);
        }
    }

    // ---- GPU timer query helpers ----
    beginGpuTimer() {
        if (!this.extTimer || !this.gl) return;
        if (this.perf.queryInFlight) return; // keep it simple: one in flight

        // Track draw calls for render FPS
        this.perf.drawCount++;

        // EXT_disjoint_timer_query is async; begin/end must bracket the draw call
        const q = this.extTimer.createQueryEXT();
        this.extTimer.beginQueryEXT(this.extTimer.TIME_ELAPSED_EXT, q);
        this.perf.queryInFlight = q;
    }

    endGpuTimer() {
        if (!this.extTimer || !this.gl) return;
        if (!this.perf.queryInFlight) return;

        this.extTimer.endQueryEXT(this.extTimer.TIME_ELAPSED_EXT);
        this.perf.pendingQueries.push(this.perf.queryInFlight);
        this.perf.queryInFlight = null;
    }

    pollGpuTimers() {
        if (!this.extTimer || !this.gl) return;

        // disjoint means the result is unreliable (clock reset / driver event)
        const disjoint = this.gl.getParameter(this.extTimer.GPU_DISJOINT_EXT);
        this.perf.gpuDisjoint = !!disjoint;

        if (this.perf.pendingQueries.length === 0) return;

        // Read the oldest query only (avoid stalls)
        const q = this.perf.pendingQueries[0];
        const available = this.extTimer.getQueryObjectEXT(
            q,
            this.extTimer.QUERY_RESULT_AVAILABLE_EXT
        );
        if (!available) return;

        const ns = this.extTimer.getQueryObjectEXT(q, this.extTimer.QUERY_RESULT_EXT);
        this.extTimer.deleteQueryEXT(q);
        this.perf.pendingQueries.shift();

        if (!this.perf.gpuDisjoint) {
            const ms = ns / 1e6;
            this.perf.gpuMs = ms;
            this.perf.gpuMsSmoothed = this.smooth(this.perf.gpuMsSmoothed, ms, 0.15);
            this.perf.gpuLastUpdateTs = performance.now();
        } else {
            this.perf.gpuMs = NaN;
            // keep previous smoothed value; disjoint is a transient condition
        }
    }

    smooth(prev, next, alpha) {
        if (!Number.isFinite(next)) return prev;
        if (!Number.isFinite(prev)) return next;
        return prev + (next - prev) * alpha;
    }

    update = (ts) => {
        if (!this.debugInfo || !this.fractalApp) return;

        const t0 = performance.now();

        // Poll GPU timers first (results arrive later)
        this.pollGpuTimers();

        // rAF timing (debug panel update rate)
        const dt = ts - this.perf.lastRafTs;
        this.perf.lastRafTs = ts;
        this.perf.frameMs = dt;
        this.perf.frameMsSmoothed = this.smooth(this.perf.frameMsSmoothed, dt, 0.1);
        this.perf.fps = dt > 0 ? 1000 / dt : 0;

        // Calculate actual render FPS (based on draw() calls)
        const now = performance.now();
        const drawCountInterval = now - this.perf.lastDrawCountReset;
        if (drawCountInterval >= 1000) {
            // Update render FPS every second
            this.perf.renderFps = (this.perf.drawCount * 1000) / drawCountInterval;
            this.perf.drawCount = 0;
            this.perf.lastDrawCountReset = now;
        }

        const dpr = window.devicePixelRatio || 1;

        const gl = this.gl;
        if (!gl || typeof gl.getShaderPrecisionFormat !== "function") return;

        const hp = gl.getShaderPrecisionFormat(gl.FRAGMENT_SHADER, gl.HIGH_FLOAT);

        const hpInfo = {precision: hp.precision, rangeMin: hp.rangeMin, rangeMax: hp.rangeMax};

        const viewPanX = ddValue(this.fractalApp.panDD.x);
        const viewPanY = ddValue(this.fractalApp.panDD.y);
        const cx = isJuliaMode() ? this.fractalApp.c[0] : 0;
        const cy = isJuliaMode() ? this.fractalApp.c[1] : 0;

        const refPanX = this.fractalApp.pan[0];
        const refPanY = this.fractalApp.pan[1];

        const zoom = this.fractalApp.zoom;
        const safeZoom = Math.max(zoom, 1e-300);

        // Scale diagnostics
        const pxPerUnit = this.canvas.width / safeZoom;

        // Precision stress heuristics
        const absPan = Math.hypot(viewPanX, viewPanY);
        const panOverZoom = absPan / safeZoom;
        const mantissaUsedApprox = Math.log2(Math.max(panOverZoom, 1e-300));

        // Perturbation drift
        const driftAbs = Math.hypot(viewPanX - refPanX, viewPanY - refPanY);
        const driftViewUnits = driftAbs / safeZoom;

        const orbitStatus = this.fractalApp.orbitDirty ? "DIRTY" : "cached";
        const zoomBucket = Math.floor(-Math.log10(safeZoom));

        // ---------- Coloring helpers ----------
        const levelClass = (level) => {
            if (level === "bad") return "dbg-bad";
            if (level === "warn") return "dbg-warn";
            return "dbg-ok";
        };

        // ---------- Precision health model ----------
        let score = 100;

        let panLevel = "ok";
        if (mantissaUsedApprox > 44) {
            panLevel = "bad";
            score -= 40;
        } else if (mantissaUsedApprox > 34) {
            panLevel = "warn";
            score -= 20;
        } else if (mantissaUsedApprox > 28) {
            panLevel = "warn";
            score -= 10;
        }

        let driftLevel = "ok";
        if (driftViewUnits > 1.0) {
            driftLevel = "bad";
            score -= 25;
        } else if (driftViewUnits > 0.5) {
            driftLevel = "warn";
            score -= 12;
        } else if (driftViewUnits > 0.25) {
            driftLevel = "warn";
            score -= 6;
        }

        let scaleLevel = "ok";
        if (pxPerUnit < 0.75) {
            scaleLevel = "warn";
            score -= 10;
        }
        if (pxPerUnit < 0.25) {
            scaleLevel = "bad";
            score -= 20;
        }

        score = Math.max(0, Math.min(100, Math.round(score)));

        let healthLevel = "ok";
        if (score < 70) healthLevel = "warn";
        if (score < 45) healthLevel = "bad";

        const worst =
            healthLevel === "bad" || healthLevel === "warn"
                ? ` (pan:${panLevel}, drift:${driftLevel}, scale:${scaleLevel})`
                : "";

        // ---------- Performance / bottleneck heuristics ----------
        // Typical: if GPU_ms_smooth is close to frame budget, you're GPU-bound.
        const frameBudgetMs = 1000 / 60; // 16.67ms
        const gpuSmooth = this.perf.gpuMsSmoothed;
        const gpuLevel =
            !Number.isFinite(gpuSmooth) ? "ok"
                : gpuSmooth > 22 ? "bad"
                    : gpuSmooth > 14 ? "warn"
                        : "ok";

        const fpsLevel =
            this.perf.renderFps < 30 ? "bad"
                : this.perf.renderFps < 50 ? "warn"
                    : "ok";

        // panel CPU time (debug UI overhead)
        const panelCpuMs = performance.now() - t0;
        this.perf.panelCpuMs = panelCpuMs;
        this.perf.panelCpuMsSmoothed = this.smooth(this.perf.panelCpuMsSmoothed, panelCpuMs, 0.2);

        // Check if GPU measurements are stale (no recent draws)
        const gpuAge = performance.now() - this.perf.gpuLastUpdateTs;
        const gpuIsStale = gpuAge > 500;

        const gpuHint =
            this.perf.gpuSupported
                ? (Number.isFinite(gpuSmooth)
                    ? (gpuIsStale
                        ? "static"
                        : (gpuSmooth > frameBudgetMs ? "GPU-bound" : "OK"))
                    : "awaiting measurement")
                : "No GPU timer";

        // ---------- Output ----------

        // Animation state
        const animState = [];
        if (this.fractalApp.currentPanAnimationFrame) animState.push('pan');
        if (this.fractalApp.currentZoomAnimationFrame) animState.push('zoom');
        if (this.fractalApp.currentRotationAnimationFrame) animState.push('rotation');
        if (this.fractalApp.currentColorAnimationFrame) animState.push('color');
        if (this.fractalApp.currentCAnimationFrame) animState.push('c');
        const animStatus = animState.length > 0 ? animState.join(', ') : 'none';
        const isAnim = isAnimationActive();

        // Rotation (convert to degrees for readability)
        const rotationRad = this.fractalApp.rotation || 0;
        const rotationDeg = (rotationRad * 180 / Math.PI) % 360;

        // Color/palette info
        const paletteIdx = this.fractalApp.currentPaletteIndex;
        const paletteName = paletteIdx >= 0 && this.fractalApp.PALETTES?.[paletteIdx]?.id
            ? this.fractalApp.PALETTES[paletteIdx].id
            : (paletteIdx === -1 ? 'Random/Cycling' : 'n/a');
        const colorPalette = this.fractalApp.colorPalette || [0, 0, 0];

        // Mandelbrot-specific: frequency and phase
        const hasFreqPhase = this.fractalApp.frequency && this.fractalApp.phase;
        const freqStr = hasFreqPhase
            ? `[${this.fractalApp.frequency.map(v => v.toFixed(2)).join(', ')}]`
            : 'n/a';
        const phaseStr = hasFreqPhase
            ? `[${this.fractalApp.phase.map(v => v.toFixed(2)).join(', ')}]`
            : 'n/a';

        // DD precision breakdown
        const panDDx = this.fractalApp.panDD?.x;
        const panDDy = this.fractalApp.panDD?.y;
        const ddXhi = panDDx?.hi ?? 0;
        const ddXlo = panDDx?.lo ?? 0;
        const ddYhi = panDDy?.hi ?? 0;
        const ddYlo = panDDy?.lo ?? 0;

        // Last interaction time
        const lastInteraction = this.fractalApp._lastInteractionTime;
        const timeSinceInteraction = lastInteraction
            ? ((performance.now() - lastInteraction) / 1000).toFixed(1) + 's ago'
            : 'n/a';

        const gpuVendor = this.gpu.unmaskedVendor || this.gpu.vendor || "unknown";
        const gpuRenderer = this.gpu.unmaskedRenderer || this.gpu.renderer || "unknown";

        this.debugInfo.innerHTML = `
            <span class="dbg-title" id="copyDebugInfo">DEBUG PANEL</span><span class="dbg-dim"> ('L' to toggle, middle-click to copy)</span><br/>
            <span class="dbg-dim">───────────────────────────────────────────────────────</span><br/>
            <span class="dbg-title">FRAG highp</span>: precision=${esc(hpInfo.precision)} range=[${esc(hpInfo.rangeMin)}, ${esc(hpInfo.rangeMax)}]<br/>
            <span class="dbg-title">GPU</span>: ${esc(gpuRenderer)} <span class="dbg-dim">(${esc(gpuVendor)})</span><br/>
            <span class="dbg-title">Mode:</span> ${esc(getFractalMode())} <span class="dbg-dim">|</span> <span class="dbg-title">Canvas:</span> ${esc(this.canvas.width)}x${esc(this.canvas.height)} <span class="dbg-dim">(dpr=${esc(dpr)})</span><br/>
            <br/>
            <span class="dbg-title">———— Transform ————</span><br/>
            <span class="dbg-title">pan</span>=[${esc(viewPanX.toFixed(18))}, ${esc(viewPanY.toFixed(18))}]<br/>
            <span class="dbg-dim">  DD.x: hi=${esc(ddXhi.toExponential(6))} lo=${esc(ddXlo.toExponential(6))}</span><br/>
            <span class="dbg-dim">  DD.y: hi=${esc(ddYhi.toExponential(6))} lo=${esc(ddYlo.toExponential(6))}</span><br/>
            <span class="dbg-title">zoom</span>=${esc(zoom.toExponential(6))} <span class="dbg-dim">(1e-${esc(zoomBucket)})</span><br/>
            <span class="dbg-title">rotation</span>=${esc(rotationDeg.toFixed(2))}° <span class="dbg-dim">(${esc(rotationRad.toFixed(4))} rad)</span><br/>
            ${isJuliaMode() ? `<span class="dbg-title">c</span>=[${esc(cx.toFixed(12))}, ${esc(cy.toFixed(12))}]<br/>` : ''}
            <br/>
            <span class="dbg-title">———— Coloring ————</span><br/>
            <span class="dbg-title">palette</span>: <span class="${paletteIdx === -1 ? 'dbg-warn' : 'dbg-ok'}">${esc(paletteName)}</span> <span class="dbg-dim">(idx=${esc(paletteIdx)})</span><br/>
            <span class="dbg-title">theme</span>=[${colorPalette.map(v => v.toFixed(3)).join(', ')}]<br/>
            ${hasFreqPhase ? `<span class="dbg-title">freq</span>=${esc(freqStr)} <span class="dbg-title">phase</span>=${esc(phaseStr)}<br/>` : ''}
            <br/>
            <span class="dbg-title">———— State ————</span><br/>
            <span class="dbg-title">animations</span>: <span class="${animState.length > 0 ? 'dbg-warn' : 'dbg-ok'}">${esc(animStatus)}</span> ${isAnim ? '<span class="dbg-badge dbg-warn">ANIM</span>' : ''}<br/>
            <span class="dbg-title">iters</span>=${esc(this.fractalApp.iterations)} <span class="dbg-dim">(max=${esc(this.fractalApp.MAX_ITER)})</span><br/>
            <span class="dbg-title">orbit</span>=<span class="${this.fractalApp.orbitDirty ? 'dbg-warn' : 'dbg-ok'}">${esc(orbitStatus)}</span><br/>
            <span class="dbg-title">lastInput</span>: <span class="dbg-dim">${esc(timeSinceInteraction)}</span><br/>
            <br/>
            <span class="dbg-title">———— Precision ————</span><br/>
            <span class="dbg-title">health</span>:<span class="dbg-badge ${levelClass(healthLevel)}">${score}/100</span><span class="dbg-dim">${esc(worst)}</span><br/>
            px/unit=<span class="${levelClass(scaleLevel)}">${esc(pxPerUnit.toExponential(2))}</span> <span class="dbg-dim">|</span> log2(pan/z)=<span class="${levelClass(panLevel)}">${esc(mantissaUsedApprox.toFixed(1))}</span><br/>
            ref drift=<span class="${levelClass(driftLevel)}">${esc(driftViewUnits.toFixed(4))}</span> <span class="dbg-dim">view-units</span><br/>
            <br/>
            <span class="dbg-title">———— Performance ————</span><br/>
            <span class="dbg-title">renderFPS</span>=<span class="${levelClass(fpsLevel)}">${esc(this.perf.renderFps.toFixed(1))}</span> <span class="dbg-dim">(rAF=${esc(this.perf.fps.toFixed(0))})</span><br/>
            <span class="dbg-title">GPU</span>=<span class="${levelClass(gpuLevel)}">${this._renderGpuTime(gpuSmooth)}</span> <span class="dbg-dim">${esc(gpuHint)}</span><br/>
            ${this._renderAdaptiveQuality()}<br/>
            `;

        requestAnimationFrame(this.update);
    };

    /**
     * Renders adaptive quality status for the debug panel.
     * @returns {string} HTML string for adaptive quality info
     */
    _renderAdaptiveQuality() {
        const enabled = this.fractalApp.adaptiveQualityEnabled;

        // Calculate FPS thresholds for display
        const minFps = Math.round(1000 / ADAPTIVE_QUALITY_THRESHOLD_HIGH);
        const targetFps = Math.round(1000 / ADAPTIVE_QUALITY_THRESHOLD_LOW);

        if (!enabled) {
            return `<span class="dbg-title">adaptQ</span>: <span class="dbg-dim">OFF</span> <span class="dbg-dim">(F to toggle)</span>`;
        }

        const extraIters = this.fractalApp.extraIterations || 0;
        const adaptiveMin = this.fractalApp.adaptiveQualityMin || ADAPTIVE_QUALITY_MIN;
        const gpuMs = this.perf.gpuMsSmoothed;
        const interacting = this.fractalApp.interactionActive;

        // Determine current state
        let state = 'stable';
        let stateClass = 'dbg-ok';

        if (interacting) {
            state = 'paused';
            stateClass = 'dbg-dim';
        } else if (Number.isFinite(gpuMs)) {
            if (gpuMs > ADAPTIVE_QUALITY_THRESHOLD_HIGH && extraIters > adaptiveMin) {
                state = 'reducing';
                stateClass = 'dbg-warn';
            } else if (gpuMs < ADAPTIVE_QUALITY_THRESHOLD_LOW && extraIters < 0) {
                state = 'restoring';
                stateClass = 'dbg-ok';
            }
        }

        // Quality level: 0 extraIters = 100%, negative = below, positive = above
        const qualityPct = 100 + Math.round(100 * extraIters / Math.abs(adaptiveMin));
        const qualityClass = extraIters < 0 ? (qualityPct < 50 ? 'dbg-bad' : 'dbg-warn') : (extraIters > 0 ? 'dbg-ok' : 'dbg-dim');
        const maxExtra = Math.abs(adaptiveMin);

        return `<span class="dbg-title">adaptQ</span>: <span class="${qualityClass}">${extraIters > 0 ? '+' : ''}${extraIters}</span> <span class="dbg-dim">[${adaptiveMin}..+${maxExtra}]</span> <span class="dbg-dim">(${qualityPct}%)</span> <span class="${stateClass}">[${state}]</span> <span class="dbg-dim">[${minFps}&larr;${targetFps} FPS]</span>`;
    }

    /**
     * Renders GPU time with staleness indicator.
     * @param {number} gpuSmooth - Smoothed GPU time in ms
     * @returns {string} HTML string for GPU time
     */
    _renderGpuTime(gpuSmooth) {
        if (!Number.isFinite(gpuSmooth)) {
            return 'n/a';
        }

        const now = performance.now();
        const gpuAge = now - this.perf.gpuLastUpdateTs;
        const isStale = gpuAge > 500; // Consider stale after 500ms without updates

        if (isStale) {
            return `<span class="dbg-dim">${gpuSmooth.toFixed(2)}ms (stale)</span>`;
        }

        return `${gpuSmooth.toFixed(2)}ms`;
    }

    /** Toggle the visibility of the debug panel. */
    toggle = () => {
        if (!this.debugInfo) return; // Safety check
        this.debugInfo.style.display = this.debugInfo.style.display === 'block' ? 'none' : 'block';
    }
}