/**
 * Tests for components/context-overflow.js
 *
 * Specifically locks the AbortController race-fix: when a new
 * loadContextData() supersedes an in-flight one, the older fetch's
 * AbortSignal must fire so its (slower) response cannot overwrite
 * the newer one's render.
 */

beforeAll(async () => {
    // Mark as a test environment so the file's auto-init doesn't fire
    // (it would query DOM elements we haven't built yet).
    window.__VITEST_TEST__ = true;

    // Stub Chart constructor — happy-dom can't run Chart.js, and we only
    // care that the page wires it up correctly. Records last-call args
    // so tests can assert on the chart options (annotations, etc.).
    globalThis.Chart = class {
        constructor(ctx, config) {
            globalThis.Chart.lastCall = { ctx, config };
            globalThis.Chart.allCalls ||= [];
            globalThis.Chart.allCalls.push({ ctx, config });
        }
        destroy() {}
    };

    // Stub URLValidator — used by the scatter chart's onClick handler.
    globalThis.URLValidator = { safeAssign: () => {} };

    // Set up the minimum DOM the loader touches when called directly.
    document.body.innerHTML = `
        <div id="loading"></div>
        <div id="content"></div>
    `;

    await import('@js/components/context-overflow.js');
});

afterAll(() => {
    delete window.__VITEST_TEST__;
});

describe('loadContextData — abort-on-supersede', () => {
    it('aborts the in-flight fetch when a newer call starts', async () => {
        let firstAbortFired = false;

        // First call: never-resolving fetch. We capture its AbortSignal.
        // Second call should trigger abort on the first.
        let firstSignal;
        const fetchMock = vi.fn((url, opts) => {
            if (!firstSignal) {
                firstSignal = opts.signal;
                firstSignal.addEventListener('abort', () => {
                    firstAbortFired = true;
                });
                return new Promise(() => {}); // never settles
            }
            // Second call: resolve with a minimal valid payload so
            // the loader's success path completes without throwing.
            return Promise.resolve({
                ok: true,
                json: () => Promise.resolve({
                    status: 'success',
                    overview: {
                        truncation_rate: 0,
                        truncated_requests: 0,
                        requests_with_context_data: 0,
                        total_requests: 0,
                        avg_tokens_truncated: 0,
                    },
                    token_summary: { total_requests: 0 },
                    model_stats: [],
                    recent_truncated: [],
                    chart_data: [],
                    context_limits: [],
                    all_requests: [],
                    pagination: { page: 1, total_pages: 1 },
                }),
            });
        });
        global.fetch = fetchMock;

        const ctrl = window.contextOverflowController;
        // Fire-and-forget the first; it never resolves on its own.
        ctrl.loadContextData('30d');
        // Newer call — should abort the first.
        await ctrl.loadContextData('7d');

        expect(firstAbortFired).toBe(true);
        // Second fetch was actually issued (not just an abort).
        expect(fetchMock).toHaveBeenCalledTimes(2);
        // Both fetches were given AbortSignals (defensive contract check).
        expect(fetchMock.mock.calls[0][1]).toHaveProperty('signal');
        expect(fetchMock.mock.calls[1][1]).toHaveProperty('signal');
    });

    it('passes the period through to the request URL', async () => {
        const fetchMock = vi.fn(() => Promise.resolve({
            ok: true,
            json: () => Promise.resolve({
                status: 'success',
                overview: {
                    truncation_rate: 0,
                    truncated_requests: 0,
                    requests_with_context_data: 0,
                    total_requests: 0,
                    avg_tokens_truncated: 0,
                },
                token_summary: { total_requests: 0 },
                model_stats: [],
                recent_truncated: [],
                chart_data: [],
                context_limits: [],
                all_requests: [],
                pagination: { page: 1, total_pages: 1 },
            }),
        }));
        global.fetch = fetchMock;

        await window.contextOverflowController.loadContextData('1y', 3);

        const url = fetchMock.mock.calls[0][0];
        expect(url).toContain('period=1y');
        expect(url).toContain('page=3');
    });
});

describe('displayContextOverflowSection — restored UI features', () => {
    // Set up the static DOM elements that the page expects (these live in
    // context_overflow.html outside the dynamic context-overflow-section).
    function setupPageDom() {
        document.body.innerHTML = `
            <div id="loading"></div>
            <div id="content"></div>
            <div id="empty-no-data"></div>
            <div id="empty-no-truncation"></div>
            <div id="empty-no-context-data"></div>
            <div id="warning-banner"></div>
            <span id="warning-rate"></span>
            <div id="context-overflow-section"></div>
            <tbody id="requests-tbody"></tbody>
            <span id="pagination-info"></span>
            <button id="pagination-prev"></button>
            <button id="pagination-next"></button>
        `;
    }

    function richPayload() {
        return {
            status: 'success',
            overview: {
                truncation_rate: 5,
                truncated_requests: 2,
                requests_with_context_data: 10,
                total_requests: 12,
                avg_tokens_truncated: 150,
            },
            token_summary: { total_requests: 12 },
            model_stats: [
                {
                    model: 'gpt-4',
                    provider: 'openai',
                    total_requests: 10,
                    truncated_count: 2,
                    truncation_rate: 20,
                    avg_context_limit: 8192,
                },
            ],
            model_token_stats: [
                {
                    model: 'gpt-4',
                    provider: 'openai',
                    min_prompt: 100,
                    avg_prompt: 4000,
                    max_prompt: 7500,
                    avg_response_time_ms: 1200,
                },
            ],
            recent_truncated: [],
            chart_data: [
                {
                    timestamp: new Date().toISOString(),
                    prompt_tokens: 4000,
                    actual_prompt_tokens: 4000,
                    context_limit: 8192,
                    research_phase: 'search',
                    research_id: 'r1',
                    model: 'gpt-4',
                    response_time_ms: 1200,
                },
            ],
            context_limits: [],
            phase_breakdown: [
                { phase: 'search', count: 5, total_tokens: 20000, avg_tokens: 4000 },
            ],
            current_context_window: 4096,
            all_requests: [],
            pagination: { page: 1, total_pages: 1 },
        };
    }

    it('renders phase chart card and summary table when phase_breakdown is provided', async () => {
        setupPageDom();
        global.fetch = vi.fn(() => Promise.resolve({
            ok: true,
            json: () => Promise.resolve(richPayload()),
        }));

        await window.contextOverflowController.loadContextData('30d');

        // Phase card was added to the dynamic section
        expect(document.getElementById('phase-chart')).not.toBeNull();
        expect(document.getElementById('phase-summary-table')).not.toBeNull();
        // Summary table populated from phase_breakdown
        expect(document.getElementById('phase-summary-table').innerHTML)
            .toContain('search');
    });

    it('renders latency chart card', async () => {
        setupPageDom();
        global.fetch = vi.fn(() => Promise.resolve({
            ok: true,
            json: () => Promise.resolve(richPayload()),
        }));

        await window.contextOverflowController.loadContextData('30d');

        expect(document.getElementById('latency-chart')).not.toBeNull();
    });

    it('renders per-model token stats grid (min/avg/max prompt) when model_token_stats provided', async () => {
        setupPageDom();
        global.fetch = vi.fn(() => Promise.resolve({
            ok: true,
            json: () => Promise.resolve(richPayload()),
        }));

        await window.contextOverflowController.loadContextData('30d');

        const modelStatsHtml = document.getElementById('model-stats').innerHTML;
        // Min/avg/max prompt labels appear, indicating the restored grid renders
        expect(modelStatsHtml).toContain('Min Prompt');
        expect(modelStatsHtml).toContain('Avg Prompt');
        expect(modelStatsHtml).toContain('Max Prompt');
        // Context utilization progress bar
        expect(modelStatsHtml).toContain('Context utilization');
        // Average response time line
        expect(modelStatsHtml).toContain('1.2s');
    });

    it('passes context-limit annotations to the scatter chart so reference lines render', async () => {
        setupPageDom();
        globalThis.Chart.allCalls = [];
        global.fetch = vi.fn(() => Promise.resolve({
            ok: true,
            json: () => Promise.resolve(richPayload()),
        }));

        await window.contextOverflowController.loadContextData('30d');

        // Find the context chart call (looks for time-axis scatter chart with
        // an annotation block — distinguishes it from latency / phase charts).
        const contextChartCall = globalThis.Chart.allCalls.find(c =>
            c.config?.type === 'scatter'
            && c.config?.options?.scales?.x?.type === 'time'
        );
        expect(contextChartCall).toBeDefined();
        const annotations = contextChartCall.config.options.plugins.annotation.annotations;
        // limit_8192 annotation exists AND has the right yMin/yMax values
        // (presence-only check would pass even if the line was rendered at 0)
        expect(annotations.limit_8192).toBeDefined();
        expect(annotations.limit_8192.yMin).toBe(8192);
        expect(annotations.limit_8192.yMax).toBe(8192);
        expect(annotations.limit_8192.borderDash).toEqual([6, 4]);
        // current_setting line at the configured num_ctx (4096), distinct
        // styling (solid line, no dash) so users can tell it from historical
        expect(annotations.current_setting).toBeDefined();
        expect(annotations.current_setting.yMin).toBe(4096);
        expect(annotations.current_setting.borderDash).toBeUndefined();
    });

    it('separates noLimitData into its own bucket instead of conflating with cautionData', async () => {
        setupPageDom();
        globalThis.Chart.allCalls = [];
        const payload = richPayload();
        // Two points: one with a limit (lands in safeData), one without (must
        // land in noLimitData, NOT cautionData)
        payload.chart_data = [
            { ...payload.chart_data[0], original_prompt_tokens: 1000, context_limit: 8192 },
            { ...payload.chart_data[0], original_prompt_tokens: 1000, context_limit: null },
        ];
        global.fetch = vi.fn(() => Promise.resolve({
            ok: true,
            json: () => Promise.resolve(payload),
        }));

        await window.contextOverflowController.loadContextData('30d');

        const contextChartCall = globalThis.Chart.allCalls.find(c =>
            c.config?.type === 'scatter'
            && c.config?.options?.scales?.x?.type === 'time'
        );
        const datasets = contextChartCall.config.data.datasets;
        const noLimitDataset = datasets.find(d => d.label === 'No context limit reported');
        const cautionDataset = datasets.find(d => d.label?.startsWith('Caution'));

        // Point with no limit goes to its own dataset, not caution
        expect(noLimitDataset.data).toHaveLength(1);
        expect(noLimitDataset.data[0].context_limit).toBeNull();
        expect(cautionDataset.data).toHaveLength(0);
    });

    it('detects local providers using canonical names (lmstudio/llamacpp without underscores)', async () => {
        // Regression guard against the LOCAL_PROVIDERS underscore mismatch.
        // The TokenUsage.model_provider canonical values per default_settings.json
        // are 'lmstudio' / 'llamacpp' — the chart must mark these as local.
        setupPageDom();
        globalThis.Chart.allCalls = [];
        const payload = richPayload();
        payload.chart_data = [
            { ...payload.chart_data[0], provider: 'lmstudio', original_prompt_tokens: 1000, context_limit: 8192 },
            { ...payload.chart_data[0], provider: 'llamacpp', original_prompt_tokens: 1000, context_limit: 8192 },
            { ...payload.chart_data[0], provider: 'openai', original_prompt_tokens: 1000, context_limit: 8192 },
        ];
        global.fetch = vi.fn(() => Promise.resolve({
            ok: true,
            json: () => Promise.resolve(payload),
        }));

        await window.contextOverflowController.loadContextData('30d');

        const contextChartCall = globalThis.Chart.allCalls.find(c =>
            c.config?.type === 'scatter'
            && c.config?.options?.scales?.x?.type === 'time'
        );
        // All 3 points are <50% util → they all land in safeData
        const safeDataset = contextChartCall.config.data.datasets.find(d => d.label?.startsWith('Safe'));
        const points = safeDataset.data;
        expect(points).toHaveLength(3);
        const lmstudioPoint = points.find(p => p.provider === 'lmstudio');
        const llamacppPoint = points.find(p => p.provider === 'llamacpp');
        const openaiPoint = points.find(p => p.provider === 'openai');
        expect(lmstudioPoint.isLocal).toBe(true);
        expect(llamacppPoint.isLocal).toBe(true);
        expect(openaiPoint.isLocal).toBe(false);
    });

    it('hides the no-truncation banner when chart_data has any >80% util request even if truncated_requests is 0', async () => {
        // Empty-state guard: a request at 85% utilisation that the backend
        // hasn't yet flagged as truncated is still a problem the user should
        // see, so the "all clear" banner must not show.
        setupPageDom();
        const payload = richPayload();
        payload.overview.truncated_requests = 0;       // backend says no truncation events
        payload.overview.requests_with_context_data = 1;
        payload.chart_data = [
            {
                ...payload.chart_data[0],
                original_prompt_tokens: 7000,           // 7000 / 8192 = 85.4% > 80%
                context_limit: 8192,
            },
        ];

        global.fetch = vi.fn(() => Promise.resolve({
            ok: true,
            json: () => Promise.resolve(payload),
        }));

        await window.contextOverflowController.loadContextData('30d');

        const banner = document.getElementById('empty-no-truncation');
        // banner.style.display defaults to '' before any code touches it;
        // after displayContextData runs the banner is set to 'none' for this case
        expect(banner.style.display).toBe('none');
    });
});
