#!/usr/bin/env node
/**
 * Journal Quality Dashboard UI Tests
 *
 * Tests for /metrics/journals — the journal quality dashboard.
 *
 * Asserts on the page's real contracts (element IDs from
 * journal_quality.html, API response shapes from metrics_routes.py)
 * rather than heuristic selectors:
 *   - init completes: #ldr-loading hides, #ldr-content shows
 *   - tab bar switches panels and the active class
 *   - threshold slider live-preview + debounced save round-trips
 *     through /settings/api/search.journal_reputation.threshold
 *   - data-sources banner renders one card per source from
 *     /metrics/api/journal-data/status
 *   - "Your Research" tab resolves to exactly one of empty/content
 *
 * First-install state (reference DB not downloaded — the CI default,
 * since datasets are fetched fresh at runtime and never bundled):
 * /metrics/api/journals does NOT fast-fail in that state. Its first
 * access lazily builds the reference DB via
 * ensure_journal_data(auto_download=True) (journal_quality/db.py
 * _build_or_raise) — a synchronous multi-minute download from
 * OpenAlex/DOAJ inside the request. The test therefore gates every
 * code path that could touch that endpoint on
 * /metrics/api/journal-data/status (pure filesystem check, always
 * fast): when data is not installed the global-journals API test is
 * skipped, and the tab-switch test stubs the journals fetch so
 * clicking the tab cannot kick off the download in CI.
 *
 * Run: node test_journal_quality_ci.js
 */

const { setupTest, teardownTest, TestResults, log, navigateTo, withTimeout } = require('./test_lib');

const PAGE_PATH = '/metrics/journals';
const THRESHOLD_KEY = 'search.journal_reputation.threshold';

// ============================================================================
// Page structure & init
// ============================================================================
const PageTests = {
    async initCompletes(page) {
        // Init runs on window `load` (after Vite module scripts): it hides
        // #ldr-loading and shows #ldr-content. Wait for that contract.
        await page.waitForFunction(() => {
            const loading = document.getElementById('ldr-loading');
            const content = document.getElementById('ldr-content');
            return loading && content &&
                getComputedStyle(loading).display === 'none' &&
                getComputedStyle(content).display !== 'none';
        }, { timeout: 30000 });

        const result = await page.evaluate(() => ({
            headerText: document.querySelector('.ldr-metrics-header h1')?.textContent?.trim(),
            errorVisible: getComputedStyle(document.getElementById('ldr-error')).display !== 'none',
            errorText: document.getElementById('ldr-error')?.textContent?.trim(),
        }));

        if (!result.headerText?.includes('Journal Quality')) {
            return { passed: false, message: `Unexpected header: "${result.headerText}"` };
        }
        if (result.errorVisible) {
            return { passed: false, message: `Error box visible after init: "${result.errorText}"` };
        }
        return { passed: true, message: 'Init completed: loading hidden, content shown, no error box' };
    },

    async tabBarStructure(page) {
        const result = await page.evaluate(() => {
            const tabs = Array.from(document.querySelectorAll('.ldr-tab-bar .ldr-tab-btn'));
            return {
                tabIds: tabs.map(t => t.dataset.tab),
                activeTab: tabs.find(t => t.classList.contains('ldr-tab-active'))?.dataset.tab,
                yourResearchVisible: getComputedStyle(document.getElementById('ldr-tab-your-research')).display !== 'none',
                globalDbVisible: getComputedStyle(document.getElementById('ldr-tab-global-db')).display !== 'none',
                howItWorksVisible: getComputedStyle(document.getElementById('ldr-tab-how-it-works')).display !== 'none',
            };
        });

        const expected = ['your-research', 'global-db', 'how-it-works'];
        const missing = expected.filter(t => !result.tabIds.includes(t));
        if (missing.length > 0) {
            return { passed: false, message: `Missing tabs: ${missing.join(', ')} (found: ${result.tabIds.join(', ')})` };
        }
        // Default tab is "Your Research" — only its panel may be visible.
        const passed = result.activeTab === 'your-research' &&
            result.yourResearchVisible && !result.globalDbVisible && !result.howItWorksVisible;
        return {
            passed,
            message: passed
                ? 'Tab bar has all 3 tabs; default is "Your Research" with only its panel visible'
                : `Bad default tab state: active=${result.activeTab}, visible panels: yr=${result.yourResearchVisible}, db=${result.globalDbVisible}, how=${result.howItWorksVisible}`
        };
    },

    async yourResearchResolves(page) {
        // loadUserResearchJournals() must resolve the tab to exactly one of
        // the two terminal states: empty state (fresh user) or content.
        await page.waitForFunction(() => {
            const loading = document.getElementById('ldr-yr-loading');
            return loading && getComputedStyle(loading).display === 'none';
        }, { timeout: 30000 });

        const result = await page.evaluate(() => ({
            emptyVisible: getComputedStyle(document.getElementById('ldr-yr-empty')).display !== 'none',
            contentVisible: getComputedStyle(document.getElementById('ldr-yr-content')).display !== 'none',
        }));

        const states = [result.emptyVisible, result.contentVisible].filter(Boolean).length;
        if (states !== 1) {
            return { passed: false, message: `Expected exactly one of empty/content visible, got empty=${result.emptyVisible}, content=${result.contentVisible}` };
        }
        return {
            passed: true,
            message: `"Your Research" tab resolved to ${result.emptyVisible ? 'empty state (no research yet)' : 'content with stats'}`
        };
    },

    async tabSwitchingWorks(page, dataInstalled) {
        // The first click on "Global Database" lazily fires
        // loadJournalPage() → GET /metrics/api/journals. In the
        // first-install state that GET triggers the server's synchronous
        // fetch-on-first-use download (multi-minute, by design — see the
        // header comment), so stub just that URL with the 503 the page
        // already handles as its documented first-install response. The
        // contract under test — active-class movement and panel swap —
        // is pure DOM and unaffected by the stub.
        if (!dataInstalled) {
            await page.evaluate(() => {
                window.__ldrTestRealFetch = window.fetch;
                window.fetch = (input, init) => {
                    const url = typeof input === 'string' ? input : input.url;
                    if (url.includes('/metrics/api/journals?')) {
                        return Promise.resolve(new Response('', { status: 503 }));
                    }
                    return window.__ldrTestRealFetch(input, init);
                };
            });
        }

        let afterSwitch;
        try {
            // Click "Global Database" → active class moves, panels swap.
            await page.evaluate(() => {
                document.querySelector('.ldr-tab-btn[data-tab="global-db"]').click();
            });
            afterSwitch = await page.evaluate(() => ({
                activeTab: document.querySelector('.ldr-tab-btn.ldr-tab-active')?.dataset.tab,
                globalDbVisible: getComputedStyle(document.getElementById('ldr-tab-global-db')).display !== 'none',
                yourResearchVisible: getComputedStyle(document.getElementById('ldr-tab-your-research')).display !== 'none',
            }));

            // Switch back to leave the page in its default state for later tests.
            await page.evaluate(() => {
                document.querySelector('.ldr-tab-btn[data-tab="your-research"]').click();
            });
        } finally {
            if (!dataInstalled) {
                await page.evaluate(() => {
                    if (window.__ldrTestRealFetch) {
                        window.fetch = window.__ldrTestRealFetch;
                        delete window.__ldrTestRealFetch;
                    }
                });
            }
        }

        const passed = afterSwitch.activeTab === 'global-db' &&
            afterSwitch.globalDbVisible && !afterSwitch.yourResearchVisible;
        return {
            passed,
            message: passed
                ? 'Tab switch to Global Database moved active class and swapped panels'
                : `Tab switch failed: active=${afterSwitch.activeTab}, db=${afterSwitch.globalDbVisible}, yr=${afterSwitch.yourResearchVisible}`
        };
    },

    async globalDbControlsPresent(page) {
        const result = await page.evaluate(() => {
            const tierFilter = document.getElementById('ldr-filter-tier');
            const sourceFilter = document.getElementById('ldr-filter-source');
            return {
                hasSearch: !!document.getElementById('ldr-journal-search'),
                tierOptions: tierFilter ? tierFilter.options.length : 0,
                sourceOptions: sourceFilter ? sourceFilter.options.length : 0,
                sortableHeaders: document.querySelectorAll('.ldr-journal-table th[data-sort]').length,
                defaultSortDesc: !!document.querySelector('.ldr-journal-table th[data-sort="quality"].ldr-sorted-desc'),
                hasTbody: !!document.getElementById('ldr-journal-tbody'),
                hasPagination: !!document.getElementById('ldr-pagination'),
            };
        });

        const problems = [];
        if (!result.hasSearch) problems.push('search input missing');
        if (result.tierOptions < 2) problems.push(`tier filter has ${result.tierOptions} options`);
        if (result.sourceOptions < 2) problems.push(`source filter has ${result.sourceOptions} options`);
        if (result.sortableHeaders < 1) problems.push('no sortable headers');
        if (!result.defaultSortDesc) problems.push('default quality desc sort indicator missing');
        if (!result.hasTbody || !result.hasPagination) problems.push('table body or pagination container missing');

        return {
            passed: problems.length === 0,
            message: problems.length === 0
                ? `Global DB controls present (${result.sortableHeaders} sortable columns, tier/source filters, search)`
                : `Problems: ${problems.join('; ')}`
        };
    },

    async sourcesBannerRendered(page) {
        // checkDataStatus() injects one card per data source into
        // #ldr-sources-grid, each with a Downloaded/Not-downloaded pill.
        await page.waitForFunction(
            () => document.getElementById('ldr-sources-grid')?.children.length > 0,
            { timeout: 15000 }
        );

        const result = await page.evaluate(() => {
            const cards = Array.from(document.getElementById('ldr-sources-grid').children);
            return {
                cardCount: cards.length,
                cardsWithPill: cards.filter(c => /Downloaded|Not downloaded/.test(c.textContent)).length,
                names: cards.map(c => c.querySelector('a')?.textContent?.trim()).filter(Boolean),
            };
        });

        const passed = result.cardCount >= 3 && result.cardsWithPill === result.cardCount;
        return {
            passed,
            message: passed
                ? `Sources banner rendered ${result.cardCount} cards with status pills: ${result.names.join(', ')}`
                : `Banner incomplete: ${result.cardCount} cards, ${result.cardsWithPill} with status pill`
        };
    },
};

// ============================================================================
// Threshold slider
// ============================================================================
const ThresholdTests = {
    async livePreviewUpdates(page) {
        // Dragging the top slider must update the value label, the
        // explanation text, and sync the second slider — without saving.
        const result = await page.evaluate(() => {
            const slider = document.getElementById('ldr-threshold-slider-top');
            slider.value = '7';
            slider.dispatchEvent(new Event('input'));
            return {
                valueLabel: document.getElementById('ldr-threshold-value-top')?.textContent?.trim(),
                explanation: document.getElementById('ldr-threshold-explanation-top')?.textContent || '',
                otherSliderValue: document.getElementById('ldr-threshold-slider')?.value,
            };
        });

        // "Good quality" is THRESHOLD_DESCRIPTIONS[7].label in
        // journal_quality.html — the label text is part of the asserted
        // contract; update both together if the copy changes.
        const passed = result.valueLabel === '7' &&
            result.explanation.includes('Good quality') &&
            result.otherSliderValue === '7';
        return {
            passed,
            message: passed
                ? 'Slider input updates value label, explanation, and syncs the second slider'
                : `Live preview wrong: label=${result.valueLabel} (expected "7"), synced=${result.otherSliderValue}, explanation expected "Good quality" but got "${result.explanation.slice(0, 60)}"`
        };
    },

    async changePersistsToSettings(page) {
        // The slider's change handler debounces 300 ms, PUTs the setting,
        // and shows "✓ Saved". Verify the full round-trip via the GET API.
        const original = await page.evaluate(async (key) => {
            const resp = await fetch(`/settings/api/${key}`);
            const data = await resp.json();
            return (data.setting && data.setting.value) ?? data.value;
        }, THRESHOLD_KEY);

        if (typeof original !== 'number') {
            return { passed: false, message: `Could not read current threshold (got ${JSON.stringify(original)})` };
        }

        // Pick a target that differs from the current value (both are
        // inside the slider's valid 1–10 range).
        const target = original === 5 ? 6 : 5;

        // Mutate-then-verify inside try/catch and restore unconditionally
        // afterwards: a transient failure (e.g. the save-status wait
        // timing out) must not leave the user's setting at `target`.
        let mutated = false;
        let statusText = null;
        let saved = null;
        let roundTripError = null;
        try {
            await page.evaluate((value) => {
                const slider = document.getElementById('ldr-threshold-slider-top');
                slider.value = String(value);
                slider.dispatchEvent(new Event('input'));
                slider.dispatchEvent(new Event('change'));
            }, target);
            mutated = true;

            // Wait for the save status to resolve (debounce 300 ms + PUT).
            await page.waitForFunction(() => {
                const status = document.getElementById('ldr-threshold-save-status-top')?.textContent || '';
                return status.includes('Saved') || status.includes('failed');
            }, { timeout: 15000 });

            statusText = await page.evaluate(() =>
                document.getElementById('ldr-threshold-save-status-top')?.textContent?.trim()
            );

            saved = await page.evaluate(async (key) => {
                const resp = await fetch(`/settings/api/${key}`);
                const data = await resp.json();
                return (data.setting && data.setting.value) ?? data.value;
            }, THRESHOLD_KEY);
        } catch (e) {
            roundTripError = e.message;
        }

        // Restore the original value so the test doesn't leak state —
        // even when the round-trip above failed part-way.
        let restored = true;
        if (mutated) {
            restored = await page.evaluate(async ([key, value]) => {
                const csrf = document.querySelector('meta[name="csrf-token"]');
                const headers = { 'Content-Type': 'application/json' };
                if (csrf) headers['X-CSRFToken'] = csrf.content;
                const resp = await fetch(`/settings/api/${key}`, {
                    method: 'PUT', headers, body: JSON.stringify({ value }),
                });
                return resp.ok;
            }, [THRESHOLD_KEY, original]);
        }

        if (!restored) {
            return { passed: false, message: `FAILED to restore original value ${original} after mutating to ${target} — user setting is dirty` };
        }
        if (roundTripError) {
            return { passed: false, message: `Round-trip error (setting restored to ${original}): ${roundTripError}` };
        }
        const passed = statusText?.includes('Saved') && saved === target;
        return {
            passed,
            message: passed
                ? `Slider change persisted: ${original} → ${target} (status "${statusText}"), then restored to ${original}`
                : `Persist failed: status="${statusText}", GET returned ${JSON.stringify(saved)} (expected ${target}); setting restored to ${original}`
        };
    },
};

// ============================================================================
// API contracts
// ============================================================================
const ApiTests = {
    async journalDataStatusContract(page) {
        const result = await page.evaluate(async () => {
            const resp = await fetch('/metrics/api/journal-data/status');
            if (!resp.ok) return { ok: false, status: resp.status };
            const data = await resp.json();
            return {
                ok: true,
                availableIsBool: typeof data.available === 'boolean',
                sourcesIsArray: Array.isArray(data.sources),
                sourceCount: Array.isArray(data.sources) ? data.sources.length : 0,
                sourcesShapeOk: Array.isArray(data.sources) && data.sources.every(
                    s => typeof s.name === 'string' && typeof s.present === 'boolean'
                ),
                hasLatestVersion: typeof data.latest_version === 'string',
            };
        });

        if (!result.ok) {
            return { passed: false, message: `Status endpoint returned HTTP ${result.status}` };
        }
        const passed = result.availableIsBool && result.sourcesIsArray &&
            result.sourceCount >= 3 && result.sourcesShapeOk && result.hasLatestVersion;
        return {
            passed,
            message: passed
                ? `journal-data/status contract OK (${result.sourceCount} sources, available + latest_version present)`
                : `Contract violation: ${JSON.stringify(result)}`
        };
    },

    async userResearchContract(page) {
        const result = await page.evaluate(async () => {
            const resp = await fetch('/metrics/api/journals/user-research');
            if (!resp.ok) return { ok: false, status: resp.status };
            const data = await resp.json();
            return {
                ok: true,
                status: data.status,
                hasSummary: typeof data.summary === 'object' && data.summary !== null,
                totalJournals: data.summary?.total_journals,
                journalsIsArray: Array.isArray(data.journals),
            };
        });

        if (!result.ok) {
            return { passed: false, message: `user-research endpoint returned HTTP ${result.status}` };
        }
        const passed = result.status === 'success' && result.hasSummary &&
            typeof result.totalJournals === 'number' && result.journalsIsArray;
        return {
            passed,
            message: passed
                ? `user-research contract OK (${result.totalJournals} journals for this user)`
                : `Contract violation: ${JSON.stringify(result)}`
        };
    },

    async globalJournalsContract(page, dataInstalled) {
        // Only exercised when the reference DB is installed: in the
        // first-install state this endpoint does not fast-fail — it
        // synchronously downloads + builds the reference DB inside the
        // request (multi-minute; see header comment), which would both
        // time out this sub-test and burn CI bandwidth.
        if (!dataInstalled) {
            return {
                passed: null,
                skipped: true,
                message: 'Reference DB not installed — querying /metrics/api/journals would trigger the synchronous fetch-on-first-use download; contract is covered on installed systems'
            };
        }

        // status said the data is installed, so anything but a fast
        // success here (including 503) is a real contract violation.
        const result = await page.evaluate(async () => {
            const resp = await fetch('/metrics/api/journals?page=1&per_page=25&sort=quality&order=desc');
            if (!resp.ok) return { ok: false, status: resp.status };
            if (!resp.headers.get('content-type')?.includes('application/json')) {
                return { ok: false, status: resp.status, badContentType: true };
            }
            const data = await resp.json();
            return {
                ok: true,
                status: data.status,
                journalsIsArray: Array.isArray(data.journals),
                hasPagination: typeof data.pagination === 'object' && data.pagination !== null,
            };
        });

        if (!result.ok) {
            return { passed: false, message: `journals API failed despite status reporting data installed: HTTP ${result.status}${result.badContentType ? ' (non-JSON body)' : ''}` };
        }
        const passed = result.status === 'success' && result.journalsIsArray && result.hasPagination;
        return {
            passed,
            message: passed
                ? 'journals API contract OK (success + journals[] + pagination)'
                : `Contract violation: ${JSON.stringify(result)}`
        };
    },
};

// ============================================================================
// Main Test Runner
// ============================================================================
async function main() {
    log.section('Journal Quality Dashboard Tests');

    const ctx = await setupTest({ authenticate: true });
    const results = new TestResults('Journal Quality Tests');
    const { page } = ctx;
    const { baseUrl } = ctx.config;

    // Collect uncaught page exceptions for the whole run — a page that
    // throws during init is a regression even if individual asserts pass.
    const pageErrors = [];
    page.on('pageerror', (err) => pageErrors.push(err.message));

    // Set per test below — whether the journal reference data is
    // installed on this server (pure filesystem check, always fast).
    // Gates every code path that could touch /metrics/api/journals,
    // because in the first-install state that endpoint synchronously
    // downloads the data inside the request (see header comment).
    let dataInstalled = false;

    const subTestTimeout = ctx.config.isCI ? 60000 : 30000;
    async function run(category, name, testFn) {
        try {
            const result = await withTimeout(testFn(page, dataInstalled), subTestTimeout, `${category}/${name}`);
            if (result.skipped) {
                results.skip(category, name, result.message);
            } else {
                results.add(category, name, result.passed, result.message);
            }
        } catch (error) {
            results.add(category, name, false, `Error: ${error.message}`);
        }
    }

    try {
        // Navigate once and wait for the full `load` event — the page's
        // init handler is bound to window load (after Vite module scripts).
        await navigateTo(page, `${baseUrl}${PAGE_PATH}`, { waitUntil: 'load' });

        // Snapshotted once at suite start; not re-checked mid-run.
        dataInstalled = await page.evaluate(async () => {
            try {
                const resp = await fetch('/metrics/api/journal-data/status');
                const data = await resp.json();
                return data.available === true;
            } catch {
                return false;
            }
        });
        log.info(`Journal reference data installed: ${dataInstalled}`);

        log.section('Page Structure & Init');
        await run('Page', 'Init Completes', PageTests.initCompletes);
        await run('Page', 'Tab Bar Structure', PageTests.tabBarStructure);
        await run('Page', 'Your Research Tab Resolves', PageTests.yourResearchResolves);
        await run('Page', 'Tab Switching Works', PageTests.tabSwitchingWorks);
        await run('Page', 'Global DB Controls Present', PageTests.globalDbControlsPresent);
        await run('Page', 'Sources Banner Rendered', PageTests.sourcesBannerRendered);

        log.section('Threshold Slider');
        await run('Threshold', 'Live Preview Updates', ThresholdTests.livePreviewUpdates);
        await run('Threshold', 'Change Persists To Settings', ThresholdTests.changePersistsToSettings);

        log.section('API Contracts');
        await run('API', 'Journal Data Status Contract', ApiTests.journalDataStatusContract);
        await run('API', 'User Research Contract', ApiTests.userResearchContract);
        await run('API', 'Global Journals Contract', ApiTests.globalJournalsContract);

        log.section('Page Errors');
        results.add(
            'Errors', 'No Uncaught Page Exceptions',
            pageErrors.length === 0,
            pageErrors.length === 0
                ? 'No uncaught exceptions during the whole run'
                : `Uncaught exceptions: ${pageErrors.slice(0, 3).join(' | ')}`
        );
    } catch (error) {
        log.error(`Fatal error: ${error.message}`);
        console.error(error.stack);
        results.add('Fatal', 'Test Suite Execution', false, error.message);
    } finally {
        results.print();
        results.save();
        await teardownTest(ctx);
        process.exit(results.exitCode());
    }
}

if (require.main === module) {
    main().catch(error => {
        console.error('Test runner failed:', error);
        process.exit(1);
    });
}

module.exports = { PageTests, ThresholdTests, ApiTests };
