/**
 * E2E test for `report_content` answer-only storage.
 *
 * Verifies the user-visible invariants of answer-only storage:
 *
 * 1. The chat page loads with the redesigned welcome screen — emoji,
 *    centered greeting, suggestion chips.
 * 2. Sending a message triggers research; the assistant response
 *    renders LIVE in the chat (regression fence for the "message
 *    only visible after page reload" bug fixed at chat.js).
 * 3. The rendered chat response is the answer ONLY — no inline
 *    `## Sources` or `## Research Metrics` blocks bleeding into the
 *    chat bubble (the report_content refactor's main user-facing
 *    contract).
 * 4. The "View full research" link still leads to /results/<id>
 *    where the legacy assembled view (answer + Sources + Metrics)
 *    is reconstructed by report_assembly_service.assemble_full_report.
 * 5. Reloading the chat page preserves the response — content is
 *    derived directly from research.report_content via
 *    chat/service.py:get_session_messages (no extraction step).
 *
 * Screenshots are written to tests/ui_tests/screenshots/ for each
 * meaningful step plus a periodic shot every 15 s during the research
 * wait (so a reviewer can see the in-progress UI evolve).
 *
 * Prerequisites:
 *   - Web server on http://127.0.0.1:5000 (override with BASE_URL).
 *   - Optional but required for the response-dependent steps:
 *       LDR_TEST_LLM_URL    OpenAI-compatible endpoint base URL
 *       LDR_TEST_LLM_MODEL  Model name to use (e.g. gpt-4o-mini)
 *       LDR_TEST_LLM_API_KEY  (optional; defaults to "not-needed")
 *       LDR_TEST_SEARCH_TOOL  (optional; defaults to "openalex")
 *   When LDR_TEST_LLM_URL is unset, the test skips Steps 3-5 and
 *   the response part of Step 6 with a clear note.
 */

const puppeteer = require('puppeteer');
const fs = require('fs');
const path = require('path');
const AuthHelper = require('../auth_helper');
const { getPuppeteerLaunchOptions } = require('../puppeteer_config');

const BASE_URL = process.env.BASE_URL || 'http://127.0.0.1:5000';
const isCI = !!process.env.CI;

// LLM config via env. Supports two providers:
//   Ollama (preferred when LDR_TEST_OLLAMA_URL set):
//     LDR_TEST_OLLAMA_URL=http://host:11434
//     LDR_TEST_LLM_MODEL=qwen3:4b
//   OpenAI-compatible endpoint (e.g. llama.cpp, vLLM):
//     LDR_TEST_OPENAI_URL=http://host:port/v1
//     LDR_TEST_LLM_MODEL=...
//     LDR_TEST_LLM_API_KEY=... (defaults to 'not-needed')
// Either way, set LDR_TEST_SEARCH_TOOL to override (default: openalex).
const OLLAMA_URL = process.env.LDR_TEST_OLLAMA_URL || '';
const OPENAI_URL = process.env.LDR_TEST_OPENAI_URL || '';
const LLM_MODEL = process.env.LDR_TEST_LLM_MODEL || '';
const LLM_API_KEY = process.env.LDR_TEST_LLM_API_KEY || 'not-needed';
const SEARCH_TOOL = process.env.LDR_TEST_SEARCH_TOOL || 'openalex';

const LLM_PROVIDER = OLLAMA_URL
    ? 'ollama'
    : OPENAI_URL
    ? 'openai_endpoint'
    : '';
const HAS_LLM = Boolean(LLM_PROVIDER && LLM_MODEL);

// LLM-backed research can take many minutes — bump generously when LLM
// is configured. Without an LLM the wait is shorter (we expect failure).
const TIMEOUTS = {
    navigation: isCI ? 60000 : 30000,
    selector: isCI ? 30000 : 10000,
    research: HAS_LLM ? (isCI ? 1800000 : 1200000) : 300000,
};

const SCREENSHOTS_DIR = path.join(__dirname, '..', 'screenshots');
const PROGRESS_SHOT_INTERVAL_MS = 15000;

async function snap(page, name) {
    if (!fs.existsSync(SCREENSHOTS_DIR)) {
        fs.mkdirSync(SCREENSHOTS_DIR, { recursive: true });
    }
    const filepath = path.join(
        SCREENSHOTS_DIR,
        `chat_refactor_${name}_${Date.now()}.png`
    );
    try {
        await page.screenshot({ path: filepath, fullPage: true });
        console.log(`   📸 ${path.relative(process.cwd(), filepath)}`);
    } catch (e) {
        console.log(`   📸 (screenshot failed: ${e.message})`);
    }
    return filepath;
}

function startProgressShots(page, label) {
    let counter = 0;
    let stopped = false;
    const tick = async () => {
        if (stopped) return;
        counter++;
        // Don't await snap — let it race; the periodic timer is best-effort.
        snap(page, `${label}_progress_${String(counter).padStart(2, '0')}`);
    };
    const interval = setInterval(tick, PROGRESS_SHOT_INTERVAL_MS);
    return () => {
        stopped = true;
        clearInterval(interval);
    };
}

async function waitForVisible(page, selector, timeout) {
    return page.waitForSelector(selector, {
        visible: true,
        timeout: timeout || TIMEOUTS.selector,
    });
}

async function getCsrfToken(page) {
    return page.evaluate(() => {
        const meta = document.querySelector('meta[name="csrf-token"]');
        return meta ? meta.content : null;
    });
}

async function configureLlmViaApi(page) {
    if (!HAS_LLM) return;
    console.log('🔧 Configuring LLM settings via API...');
    // Settings page renders the meta name=csrf-token tag; chat page has it too.
    // Use chat page so we don't navigate away.
    await page.goto(`${BASE_URL}/chat/`, {
        waitUntil: 'domcontentloaded',
        timeout: TIMEOUTS.navigation,
    });
    const csrfToken = await getCsrfToken(page);
    if (!csrfToken) {
        throw new Error('Could not obtain CSRF token to set settings');
    }

    const settingsToSet = {
        'llm.provider': LLM_PROVIDER,
        'llm.model': LLM_MODEL,
        'search.tool': SEARCH_TOOL,
    };
    if (LLM_PROVIDER === 'ollama') {
        settingsToSet['llm.ollama.url'] = OLLAMA_URL;
    } else if (LLM_PROVIDER === 'openai_endpoint') {
        settingsToSet['llm.openai_endpoint.url'] = OPENAI_URL;
        settingsToSet['llm.openai_endpoint.api_key'] = LLM_API_KEY;
    }

    for (const [key, value] of Object.entries(settingsToSet)) {
        const result = await page.evaluate(
            async (k, v, token, base) => {
                const res = await fetch(
                    `${base}/settings/api/${encodeURIComponent(k)}`,
                    {
                        method: 'PUT',
                        headers: {
                            'Content-Type': 'application/json',
                            'X-CSRFToken': token,
                        },
                        credentials: 'same-origin',
                        body: JSON.stringify({ value: v }),
                    }
                );
                return { status: res.status, body: await res.text() };
            },
            key,
            value,
            csrfToken,
            BASE_URL
        );
        const ok = result.status >= 200 && result.status < 300;
        console.log(
            `   ${ok ? '✓' : '✗'} ${key} = ${
                key.toLowerCase().includes('key') ? '[REDACTED]' : value
            }  (HTTP ${result.status})`
        );
        if (!ok) {
            console.log(`     body: ${result.body.slice(0, 200)}`);
        }
    }
    console.log('🔧 LLM configuration done\n');
}

async function run() {
    console.log(
        `🧪 Chat report_content refactor E2E (CI mode: ${isCI}, LLM configured: ${HAS_LLM})\n`
    );
    if (HAS_LLM) {
        console.log(`   LLM provider: ${LLM_PROVIDER}`);
        console.log(
            `   LLM URL:      ${LLM_PROVIDER === 'ollama' ? OLLAMA_URL : OPENAI_URL}`
        );
        console.log(`   LLM model:    ${LLM_MODEL}`);
        console.log(`   Search tool:  ${SEARCH_TOOL}\n`);
    }

    // Bump protocolTimeout above puppeteer_config.js's default 600s
    // when LLM is in play. A slow synthesis (cold model, big context)
    // can run >10 min — without this the protocol times out before
    // the configured TIMEOUTS.research, masking real success as a
    // generic-looking timeout failure.
    const browser = await puppeteer.launch(
        getPuppeteerLaunchOptions(
            HAS_LLM ? { protocolTimeout: TIMEOUTS.research + 60000 } : {}
        )
    );
    const page = await browser.newPage();
    await page.setViewport({ width: 1280, height: 900 });

    page.on('console', (msg) => {
        if (msg.type() === 'error') {
            console.log(`  BROWSER ERROR: ${msg.text()}`);
        }
    });
    page.on('pageerror', (e) => console.log('  PAGE ERROR:', e.message));

    const auth = new AuthHelper(page, BASE_URL);
    let passed = 0;
    let failed = 0;

    const fail = (name, err) => {
        console.log(`❌ ${name} FAILED: ${err.message || err}`);
        failed++;
    };
    const pass = (name) => {
        console.log(`✅ ${name} PASSED`);
        passed++;
    };

    try {
        console.log('🔐 Authenticating...');
        await auth.ensureAuthenticated();
        console.log('✅ Authenticated\n');

        await configureLlmViaApi(page);

        // -- Step 1: chat page loads with the redesigned welcome screen --
        console.log('📋 Step 1: chat page redesigned welcome screen');
        try {
            await page.goto(`${BASE_URL}/chat/`, {
                waitUntil: 'domcontentloaded',
                timeout: TIMEOUTS.navigation,
            });
            await waitForVisible(page, '.ldr-chat-container');
            await waitForVisible(page, '#chat-welcome');

            const welcomeText = await page.$eval(
                '#chat-welcome h2',
                (el) => el.textContent.trim()
            );
            if (!welcomeText.toLowerCase().includes('research')) {
                throw new Error(
                    `Welcome heading didn't match: "${welcomeText}"`
                );
            }

            const welcomeIcon = await page.$eval(
                '.ldr-chat-welcome-icon',
                (el) => el.textContent.trim()
            );
            if (!welcomeIcon || welcomeIcon.length === 0) {
                throw new Error('Welcome icon (emoji) is empty');
            }

            const suggestionCount = await page.$$eval(
                '.ldr-chat-suggestion',
                (els) => els.length
            );
            if (suggestionCount < 1) {
                throw new Error(
                    `Expected at least 1 suggestion chip, got ${suggestionCount}`
                );
            }

            await snap(page, 'welcome');
            pass('Step 1: welcome screen renders');
        } catch (e) {
            await snap(page, 'welcome_failed');
            fail('Step 1: welcome screen renders', e);
        }

        // -- Step 2: messages container is centered with max-width --
        console.log('\n📋 Step 2: messages container centered with max-width');
        try {
            const layout = await page.evaluate(() => {
                const messages = document.querySelector('.ldr-chat-messages');
                const cs = window.getComputedStyle(messages);
                return {
                    maxWidth: cs.maxWidth,
                    marginLeft: cs.marginLeft,
                    marginRight: cs.marginRight,
                };
            });
            if (
                !layout.maxWidth ||
                layout.maxWidth === 'none' ||
                layout.marginLeft === '0px'
            ) {
                throw new Error(
                    `Messages container not centered: ${JSON.stringify(layout)}`
                );
            }
            pass('Step 2: messages container has centered max-width layout');
        } catch (e) {
            fail('Step 2: messages container has centered max-width layout', e);
        }

        // Without an LLM the response-dependent steps will only ever see
        // status text. We still send a message (to verify the send path
        // works and the user message persists), then skip Steps 4-5
        // and run a reduced Step 6 that just verifies the user query
        // survives a reload.
        const STATUS_HINTS = [
            'Starting research process',
            'Initialising',
            'Initializing',
            'Loading model',
            'Querying',
            'Generating',
        ];
        const looksLikeStatusOnly = (body) => {
            const trimmed = (body || '').trim();
            if (trimmed.length < 100) return true;
            return STATUS_HINTS.some((h) => trimmed.startsWith(h));
        };

        let responseAvailable = false;
        // Captured at Step 5 from .ldr-chat-research-link-btn href.
        // Reused by Steps 8-10 for the results / history coverage.
        let researchId = null;

        // -- Step 3: send a message + assistant response renders LIVE --
        console.log('\n📋 Step 3: send message + assistant response renders live');
        let stopProgressShots = null;
        try {
            const testQuery = HAS_LLM
                ? 'In one short paragraph, what is photosynthesis?'
                : 'What is 2 + 2?';
            await page.click('#chat-input');
            await page.type('#chat-input', testQuery);
            await snap(page, 'before_send');

            await page.click('#send-btn');
            await waitForVisible(
                page,
                '.ldr-chat-message-user',
                TIMEOUTS.selector
            );
            await snap(page, 'message_sent');

            console.log(
                `   Waiting for substantive response (timeout ${
                    TIMEOUTS.research / 1000
                }s)...`
            );
            console.log(
                `   Periodic screenshots every ${
                    PROGRESS_SHOT_INTERVAL_MS / 1000
                }s while waiting...`
            );
            // Only stream progress screenshots when an LLM is actually configured —
            // without one there is no research to watch, and taking screenshots while
            // the failing-research socket flow churns just adds load that helps close
            // the page on the threading dev server.
            if (HAS_LLM) {
                stopProgressShots = startProgressShots(page, 'research');
            }

            try {
                // Two completion signals:
                //  1. The .ldr-chat-research-link-btn — appended by
                //     handleResearchComplete after progress=100. The
                //     most reliable "done" signal because it's the
                //     same code path as the user-visible UI.
                //  2. A non-step assistant message with substantive
                //     text (catches the streaming-only LLM case where
                //     completion event is delayed but text already
                //     rendered).
                // Some LLMs don't stream synthesis chunks (the answer
                // arrives in one final blob), so #2 may never fire
                // before #1 — we watch both.
                await page.waitForFunction(
                    (statusHints) => {
                        if (
                            document.querySelector(
                                '.ldr-chat-research-link-btn'
                            )
                        ) {
                            return true;
                        }
                        const msgs = document.querySelectorAll(
                            '.ldr-chat-message-assistant:not(.ldr-chat-message-step)'
                        );
                        for (const msg of msgs) {
                            const textEl = msg.querySelector(
                                '.ldr-chat-message-text'
                            );
                            if (!textEl) continue;
                            const t = (textEl.textContent || '').trim();
                            if (t.length < 100) continue;
                            if (statusHints.some((h) => t.startsWith(h))) {
                                continue;
                            }
                            return true;
                        }
                        return false;
                    },
                    // No LLM (e.g. the chat-lifecycle CI shard): research can never
                    // complete, so do NOT sit in the long research-wait. Sustaining the
                    // failing-research socket flow on the threading socket.io dev server
                    // for the full timeout destabilises and closes the page ("write()
                    // before start_response" / detached frame). Time out fast so the
                    // no-LLM skip path in the catch is taken right after the user
                    // message bubble renders.
                    { timeout: HAS_LLM ? TIMEOUTS.research : 100, polling: 500 },
                    STATUS_HINTS
                );
                responseAvailable = true;
                stopProgressShots();
                stopProgressShots = null;
                await snap(page, 'response_visible');
                pass('Step 3: assistant response renders live in chat');
            } catch (waitErr) {
                if (stopProgressShots) {
                    stopProgressShots();
                    stopProgressShots = null;
                }
                const bodyNow = await page.evaluate(() => {
                    const msgs = document.querySelectorAll(
                        '.ldr-chat-message-assistant:not(.ldr-chat-message-step)'
                    );
                    const last = msgs[msgs.length - 1];
                    const textEl = last
                        ? last.querySelector('.ldr-chat-message-text')
                        : null;
                    return textEl ? textEl.textContent : null;
                });
                if (looksLikeStatusOnly(bodyNow)) {
                    if (HAS_LLM) {
                        await snap(page, 'no_response_with_llm');
                        throw new Error(
                            `Research timed out at ${
                                TIMEOUTS.research / 1000
                            }s with only status text. Body: ${JSON.stringify(
                                (bodyNow || '').slice(0, 120)
                            )}`
                        );
                    } else {
                        console.log(
                            '⏭️  Step 3 SKIPPED: no LLM configured (set LDR_TEST_LLM_URL/MODEL). Body: ' +
                                JSON.stringify((bodyNow || '').slice(0, 80))
                        );
                        await snap(page, 'no_llm_skipped');
                    }
                } else {
                    throw waitErr;
                }
            }
        } catch (e) {
            if (stopProgressShots) stopProgressShots();
            await snap(page, 'response_missing');
            fail('Step 3: assistant response renders live in chat', e);
        }

        // Step 3 fires when streaming text appears (>100 chars,
        // non-status). Steps 4-7 require the COMPLETION handler to
        // have run — that's what appends .ldr-chat-research-link-btn,
        // hides the progress wrapper, and re-enables the send button.
        // Wait for that signal before continuing. Without this gate
        // the steps race the streaming and fail spuriously.
        if (responseAvailable) {
            console.log(
                '\n⏳ Waiting for research completion (research-link button)...'
            );
            try {
                await page.waitForSelector(
                    '.ldr-chat-research-link-btn',
                    { visible: true, timeout: TIMEOUTS.research }
                );
                await snap(page, 'completed');
                console.log('   ✓ Completion signaled');
            } catch (e) {
                await snap(page, 'completion_timeout');
                fail(
                    'Wait for completion (research-link button)',
                    e
                );
                // Mark as not-available so Steps 4-7 skip cleanly
                // rather than each failing with the same root cause.
                responseAvailable = false;
            }
        }

        // -- Step 4: response body is answer-only --
        if (responseAvailable) {
            console.log(
                '\n📋 Step 4: response body is answer-only (no inline Sources/Metrics)'
            );
            try {
                // The "actual answer" message is the one carrying the
                // research link (created by handleResearchComplete via
                // _appendResearchLink). Picking by that anchor avoids
                // confusion with later status / step messages that also
                // happen to have the assistant class.
                const body = await page.evaluate(() => {
                    const link = document.querySelector(
                        '.ldr-chat-research-link-btn'
                    );
                    if (!link) return null;
                    const msg = link.closest('.ldr-chat-message-assistant');
                    if (!msg) return null;
                    const textEl = msg.querySelector('.ldr-chat-message-text');
                    return textEl ? textEl.textContent : null;
                });
                if (body == null) {
                    throw new Error(
                        'Could not find the response message via research-link anchor'
                    );
                }
                if (body.trim().length < 50) {
                    throw new Error(
                        `Response body suspiciously short (${body.length} chars): ${JSON.stringify(body.slice(0, 100))}`
                    );
                }
                if (/^#{2,3}\s+Sources\b/m.test(body)) {
                    throw new Error(
                        'Chat body contains "## Sources" header — refactor regression'
                    );
                }
                if (/^#{2,3}\s+Research Metrics\b/m.test(body)) {
                    throw new Error(
                        'Chat body contains "## Research Metrics" header — refactor regression'
                    );
                }
                console.log(
                    `   Body length: ${body.length} chars (preview: ${JSON.stringify(
                        body.slice(0, 120)
                    )}...)`
                );
                pass('Step 4: chat body is answer-only');
            } catch (e) {
                fail('Step 4: chat body is answer-only', e);
            }
        } else {
            console.log('\n⏭️  Step 4 SKIPPED (no real response)');
        }

        // -- Step 5: View full research link present + leads to /results/ --
        if (responseAvailable) {
            console.log('\n📋 Step 5: View full research link present');
            try {
                const linkInfo = await page.evaluate(() => {
                    const link = document.querySelector(
                        '.ldr-chat-research-link-btn'
                    );
                    return link ? { href: link.getAttribute('href') } : null;
                });
                if (!linkInfo) {
                    throw new Error('"View full research" link missing');
                }
                const idMatch = linkInfo.href.match(
                    /^\/results\/([a-zA-Z0-9-]+)$/
                );
                if (!idMatch) {
                    throw new Error(
                        `Research link doesn't point to /results/<id>: ${linkInfo.href}`
                    );
                }
                researchId = idMatch[1];
                pass('Step 5: research link present + correct path');

                // Bonus: navigate to the results page and verify the
                // assembled view (answer + Sources + Metrics) is present.
                // This covers the assemble_full_report read site.
                console.log(
                    '\n📋 Step 5b: results page shows the assembled view'
                );
                await page.goto(`${BASE_URL}${linkInfo.href}`, {
                    waitUntil: 'domcontentloaded',
                    timeout: TIMEOUTS.navigation,
                });
                // Give the page a moment to render
                await page.waitForFunction(
                    () => document.body.innerText.length > 200,
                    { timeout: TIMEOUTS.selector }
                );
                await snap(page, 'results_page');
                const resultsBody = await page.evaluate(
                    () => document.body.innerText
                );
                const hasSources = /## ?Sources|^Sources$/m.test(resultsBody);
                if (!hasSources) {
                    throw new Error(
                        'Results page is missing the assembled Sources section'
                    );
                }
                pass('Step 5b: results page assembles Sources block');
                // Go back to chat for Step 6
                await page.goBack({
                    waitUntil: 'domcontentloaded',
                    timeout: TIMEOUTS.navigation,
                });
            } catch (e) {
                await snap(page, 'results_link_failed');
                fail('Step 5: research link present + correct path', e);
            }
        } else {
            console.log('\n⏭️  Step 5 SKIPPED (no real response)');
        }

        // -- Step 6: page reload preserves the response --
        if (responseAvailable) {
            console.log('\n📋 Step 6: response persists after page reload');
            try {
                const sessionUrl = page.url();
                await page.reload({
                    waitUntil: 'domcontentloaded',
                    timeout: TIMEOUTS.navigation,
                });
                await waitForVisible(page, '.ldr-chat-container');
                await page.waitForFunction(
                    (statusHints) => {
                        const msgs = document.querySelectorAll(
                            '.ldr-chat-message-assistant:not(.ldr-chat-message-step)'
                        );
                        for (const msg of msgs) {
                            const textEl = msg.querySelector(
                                '.ldr-chat-message-text'
                            );
                            if (!textEl) continue;
                            const t = (textEl.textContent || '').trim();
                            if (t.length < 100) continue;
                            if (statusHints.some((h) => t.startsWith(h))) {
                                continue;
                            }
                            return true;
                        }
                        return false;
                    },
                    { timeout: TIMEOUTS.selector, polling: 500 },
                    STATUS_HINTS
                );
                await snap(page, 'after_reload');
                pass('Step 6: response persists after reload');
                console.log(`   (was at: ${sessionUrl})`);
            } catch (e) {
                await snap(page, 'reload_missing');
                fail('Step 6: response persists after reload', e);
            }
        } else {
            console.log(
                '\n📋 Step 6 (reduced): page reload preserves user query message'
            );
            try {
                await page.reload({
                    waitUntil: 'domcontentloaded',
                    timeout: TIMEOUTS.navigation,
                });
                await waitForVisible(page, '.ldr-chat-container');
                await waitForVisible(
                    page,
                    '.ldr-chat-message-user',
                    TIMEOUTS.selector
                );
                await snap(page, 'after_reload_query_only');
                pass('Step 6 (reduced): user query persists after reload');
            } catch (e) {
                await snap(page, 'reload_missing');
                fail('Step 6 (reduced): user query persists after reload', e);
            }
        }
        // -- Step 7: post-research UI is clean (no leftover spinners) --
        if (responseAvailable) {
            console.log('\n📋 Step 7: UI cleanup after research completes');
            try {
                // Only check completion-driven state, not input-driven
                // state. sendBtn.disabled is correctly tied to whether
                // chatInput has text (chat.js), so an empty input
                // legitimately keeps it disabled — that's not a bug.
                const ui = await page.evaluate(() => {
                    const thinking = document.querySelector(
                        '.ldr-chat-message-thinking'
                    );
                    const taskWrapper = document.getElementById(
                        'chat-progress-wrapper'
                    );
                    const taskWrapperVisible =
                        taskWrapper &&
                        window.getComputedStyle(taskWrapper).display !== 'none';
                    return {
                        hasThinking: !!thinking,
                        taskWrapperVisible,
                    };
                });
                if (ui.hasThinking) {
                    throw new Error(
                        'Thinking spinner is still in DOM after research completed'
                    );
                }
                if (ui.taskWrapperVisible) {
                    throw new Error(
                        'Progress wrapper (Current Task / Research Logs) still visible after completion'
                    );
                }

                // Send button SHOULD re-enable when the user types.
                // Verify the wiring still works.
                await page.click('#chat-input');
                await page.type('#chat-input', 'follow-up test');
                const sendDisabledAfterType = await page.evaluate(() => {
                    const sendBtn = document.getElementById('send-btn');
                    return sendBtn ? sendBtn.disabled : null;
                });
                // Clear the input so it doesn't trigger a follow-up send
                await page.evaluate(() => {
                    const ci = document.getElementById('chat-input');
                    if (ci) ci.value = '';
                    ci.dispatchEvent(new Event('input', { bubbles: true }));
                });
                if (sendDisabledAfterType) {
                    throw new Error(
                        'Send button stayed disabled even after typing in chat input — re-enable wiring broken'
                    );
                }
                pass('Step 7: post-research UI cleaned up correctly');
            } catch (e) {
                fail('Step 7: post-research UI cleaned up correctly', e);
            }
        } else {
            console.log('\n⏭️  Step 7 SKIPPED (no real response)');
        }

        // -- Step 8: results page deep-check --
        // Step 5b confirmed Sources is present. Step 8 goes further:
        // verify the answer body, that the Sources entries are real
        // links (clickable hrefs, not just text), and the assembled
        // shape from report_assembly_service.assemble_full_report.
        if (responseAvailable && researchId) {
            console.log(
                '\n📋 Step 8: /results/<id> deep-check (answer + linked sources)'
            );
            try {
                await page.goto(`${BASE_URL}/results/${researchId}`, {
                    waitUntil: 'domcontentloaded',
                    timeout: TIMEOUTS.navigation,
                });
                await page.waitForFunction(
                    () => document.body.innerText.length > 200,
                    { timeout: TIMEOUTS.selector }
                );
                await snap(page, 'results_page_deep');

                const summary = await page.evaluate(() => {
                    const body = document.body.innerText;
                    const sourceLinks = Array.from(
                        document.querySelectorAll('a[href]')
                    )
                        .filter(
                            (a) =>
                                /^https?:\/\//.test(a.getAttribute('href')) &&
                                !a
                                    .getAttribute('href')
                                    .includes(window.location.host)
                        )
                        .map((a) => a.getAttribute('href'));
                    return {
                        bodyLen: body.length,
                        // Look for either the synthesized answer's
                        // opening (the LLM almost always opens with
                        // "Photosynthesis") OR a citation marker so
                        // the assertion isn't tied to one model's
                        // exact prose.
                        hasAnswerProse: /photosynthesis|chloroplast|chlorophyll/i.test(
                            body
                        ),
                        hasInlineCitation: /\[\d+\]/.test(body),
                        hasSourcesHeader: /## ?Sources|^Sources$/m.test(body),
                        externalLinkCount: new Set(sourceLinks).size,
                    };
                });

                if (!summary.hasAnswerProse) {
                    throw new Error(
                        `Results page is missing answer prose (body=${summary.bodyLen} chars)`
                    );
                }
                if (!summary.hasSourcesHeader) {
                    throw new Error(
                        'Results page is missing the Sources section'
                    );
                }
                if (summary.externalLinkCount < 1) {
                    throw new Error(
                        'Results page has no external source links — Sources block did not render real <a> tags'
                    );
                }
                console.log(
                    `   answer-prose=${summary.hasAnswerProse}  inline-cite=${summary.hasInlineCitation}  external-links=${summary.externalLinkCount}`
                );
                pass(
                    'Step 8: results page renders answer + linked sources'
                );
            } catch (e) {
                await snap(page, 'results_deep_failed');
                fail(
                    'Step 8: results page renders answer + linked sources',
                    e
                );
            }
        } else {
            console.log('\n⏭️  Step 8 SKIPPED (no research id)');
        }

        // -- Step 9: history page lists the research --
        if (responseAvailable && researchId) {
            console.log(
                '\n📋 Step 9: /history lists the just-completed research'
            );
            try {
                await page.goto(`${BASE_URL}/history/`, {
                    waitUntil: 'domcontentloaded',
                    timeout: TIMEOUTS.navigation,
                });
                // history.js renders each row as
                // .ldr-history-item[data-id="<research_id>"]; the
                // chat-mode group header carries the chat session id
                // and the inner item carries the research id. Either
                // is acceptable as evidence the row landed.
                await page.waitForFunction(
                    () => {
                        return (
                            document.querySelectorAll('.ldr-history-item')
                                .length > 0
                        );
                    },
                    { timeout: TIMEOUTS.selector }
                );
                await snap(page, 'history_page');

                const present = await page.evaluate((id) => {
                    return !!document.querySelector(
                        `.ldr-history-item[data-id="${id}"]`
                    );
                }, researchId);
                if (!present) {
                    throw new Error(
                        `History page does not list research ${researchId}`
                    );
                }
                pass('Step 9: history page lists the research');
            } catch (e) {
                await snap(page, 'history_failed');
                fail('Step 9: history page lists the research', e);
            }
        } else {
            console.log('\n⏭️  Step 9 SKIPPED (no research id)');
        }

        // -- Step 10: click-through from history opens results --
        if (responseAvailable && researchId) {
            console.log(
                '\n📋 Step 10: click-through from history opens the results page'
            );
            try {
                // history.js wires .ldr-view-btn (and item click) to
                // navigate via window.location='/results/<id>'. The
                // grouped chat row hides the View button — drive the
                // navigation by clicking the item itself, which the
                // delegated handler in history.js handles.
                await Promise.all([
                    page.waitForNavigation({
                        waitUntil: 'domcontentloaded',
                        timeout: TIMEOUTS.navigation,
                    }),
                    page.evaluate((id) => {
                        const item = document.querySelector(
                            `.ldr-history-item[data-id="${id}"]`
                        );
                        if (!item) return;
                        // Prefer the View button if present; else
                        // simulate item click (delegated handler
                        // navigates to results page).
                        const viewBtn = item.querySelector('.ldr-view-btn');
                        (viewBtn || item).click();
                    }, researchId),
                ]);
                const finalUrl = page.url();
                if (!finalUrl.includes(`/results/${researchId}`)) {
                    throw new Error(
                        `After click, URL is ${finalUrl} (expected /results/${researchId})`
                    );
                }
                // Wait until content actually renders before
                // snapping — otherwise the screenshot captures the
                // "Loading research results..." spinner, which makes
                // the artifact useless for visual review.
                await page
                    .waitForFunction(
                        () => {
                            const text = document.body.innerText || '';
                            return (
                                text.length > 500 &&
                                !/loading research results/i.test(text)
                            );
                        },
                        { timeout: TIMEOUTS.selector }
                    )
                    .catch(() => {});
                await snap(page, 'history_to_results');
                pass(
                    'Step 10: history click-through reaches the correct results page'
                );
            } catch (e) {
                await snap(page, 'history_click_failed');
                fail(
                    'Step 10: history click-through reaches the correct results page',
                    e
                );
            }
        } else {
            console.log('\n⏭️  Step 10 SKIPPED (no research id)');
        }
    } finally {
        await browser.close();
    }

    console.log(
        `\n📊 Results: ${passed} passed, ${failed} failed, ${
            passed + failed
        } total`
    );
    if (failed > 0) {
        process.exit(1);
    }
}

run().catch((e) => {
    console.error('FATAL:', e);
    process.exit(1);
});
