#!/usr/bin/env node
/**
 * CRUD Operations UI Tests
 *
 * Tests for create, update, delete operations on collections, subscriptions, and documents.
 *
 * Run: node test_crud_operations_ci.js
 */

const { readFileSync } = require('fs');
const { join } = require('path');

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

// Parse strategy names from the source of truth (constants.py::AVAILABLE_STRATEGIES)
// at module load so this list can't drift if an id is renamed or a new strategy added.
const _CONSTANTS_PY = readFileSync(
    join(__dirname, '..', '..', 'src', 'local_deep_research', 'constants.py'),
    'utf8'
);
const KNOWN_STRATEGIES = [..._CONSTANTS_PY.matchAll(/"name":\s*"([^"]+)"/g)].map(m => m[1]);

// ============================================================================
// Collection CRUD Tests
// ============================================================================
const CollectionCrudTests = {
    async createCollectionFormOpens(page, baseUrl) {
        // Collection creation is a page-navigation flow, not a modal: the
        // "Create Collection" button on /library/collections is an anchor
        // to /library/collections/create. Earlier versions of this test
        // looked for a modal at the (404) /collections route and silently
        // skipped forever. We verify both halves of the real flow:
        //   1. The list page advertises a link to the create page.
        //   2. The create page renders a form with a required name input.

        await navigateTo(page, `${baseUrl}/library/collections`);
        const listInfo = await page.evaluate(() => {
            const link = document.getElementById('create-collection-btn');
            return {
                hasButton: !!link,
                href: link?.getAttribute('href'),
            };
        });
        if (!listInfo.hasButton) {
            return { passed: false, message: 'create-collection-btn missing on /library/collections' };
        }
        if (!listInfo.href || !listInfo.href.includes('/library/collections/create')) {
            return { passed: false, message: `create-collection-btn href is "${listInfo.href}", expected /library/collections/create` };
        }

        await navigateTo(page, `${baseUrl}/library/collections/create`);
        const formInfo = await page.evaluate(() => {
            // Two forms exist on every page: the create form and the global
            // logout form. Pick the create form by id (or by excluding logout).
            const createForm = document.querySelector('form:not(#logout-form)');
            const nameInput = document.querySelector('#collection-name, input[name="name"]');
            const submit = createForm?.querySelector('button[type="submit"], input[type="submit"]');
            return {
                hasForm: !!createForm,
                hasNameInput: !!nameInput,
                nameInputRequired: !!nameInput?.required,
                hasSubmit: !!submit,
                submitText: submit?.textContent?.trim() || submit?.value,
            };
        });

        const passed = formInfo.hasForm && formInfo.hasNameInput && formInfo.hasSubmit;
        return {
            passed,
            message: passed
                ? `Create page form ok (nameRequired=${formInfo.nameInputRequired}, submit="${formInfo.submitText}")`
                : `Create page missing parts (form=${formInfo.hasForm}, name=${formInfo.hasNameInput}, submit=${formInfo.hasSubmit})`
        };
    },

    async createCollectionFormValidation(page, baseUrl) {
        // The create form uses HTML5 `required` on the name input. An empty
        // submit should leave the input in :invalid state and keep us on
        // /library/collections/create (no POST). We do not look for app-level
        // error elements — the contract here is the browser's own validation.

        await navigateTo(page, `${baseUrl}/library/collections/create`);

        const result = await page.evaluate(() => {
            const form = document.querySelector('form:not(#logout-form)');
            const submit = form?.querySelector('button[type="submit"], input[type="submit"]');
            if (!form || !submit) return { hasForm: false };

            // Make sure name is empty, then try to submit.
            const name = document.querySelector('#collection-name, input[name="name"]');
            if (name) name.value = '';
            submit.click();

            return new Promise(resolve => setTimeout(() => {
                const nameEl = document.querySelector('#collection-name, input[name="name"]');
                resolve({
                    hasForm: true,
                    requiredInvalid: nameEl ? !nameEl.checkValidity() : null,
                    stayedOnPage: location.pathname.endsWith('/library/collections/create'),
                });
            }, 300));
        });

        if (!result.hasForm) {
            return { passed: false, message: 'Create form not found on /library/collections/create' };
        }

        const passed = result.requiredInvalid === true && result.stayedOnPage;
        return {
            passed,
            message: passed
                ? 'Empty submit blocked by HTML5 :invalid on required name input'
                : `Validation contract failed (requiredInvalid=${result.requiredInvalid}, stayedOnPage=${result.stayedOnPage})`
        };
    },

    async collectionDeleteConfirmation(page, baseUrl) {
        // The previous version scanned /library/collections for cards with an
        // inline delete button and SKIPped on an empty DB. The actual list
        // page renders collection cards as plain anchor links to the detail
        // page — no per-card delete affordance exists. Delete lives on
        // /library/collections/<id> as #delete-collection-btn, and the
        // confirmation is a *native* window.confirm() prompt (see
        // collection_details.js::deleteCollection), not a DOM modal — so
        // the old `document.querySelector('.modal, ...')` would never find
        // anything even if we did navigate to the right page.
        //
        // We:
        //   1. Seed a collection via POST /library/api/collections so the
        //      test does not depend on pre-existing DB state.
        //   2. Navigate to the detail page and click #delete-collection-btn.
        //   3. Capture the native confirm() prompt via page.on('dialog')
        //      and dismiss it so the seeded collection survives for cleanup.
        //   4. DELETE the seed via the API.
        //
        // The sibling collectionEditButton test was removed: the app has no
        // collection-edit UI anywhere (no analog on the list page, the
        // detail page, or anywhere in the templates), so the old test was
        // a permanent SKIP for a feature that simply does not exist.

        // Seed via the shared helper (also used by the document tests). It
        // returns the id and the generated name; we wait for that name to land
        // on the detail page before clicking. seedCollection reads the CSRF
        // meta from the current page, so be on an app page first.
        await navigateTo(page, `${baseUrl}/library/collections`);
        const seeded = await seedCollection(page);
        if (!seeded) {
            return { passed: false, message: 'Could not seed test collection' };
        }
        const { id: collectionId, name: fixtureName } = seeded;

        try {
            // Listen for the native confirm() before clicking — Puppeteer
            // delivers the dialog asynchronously once the event listener is
            // wired.
            let dialog = null;
            const onDialog = async d => {
                dialog = { type: d.type(), message: d.message() };
                await d.dismiss();
            };
            page.on('dialog', onDialog);

            try {
                await navigateTo(page, `${baseUrl}/library/collections/${collectionId}`);
                // The detail page populates collectionData via an async fetch on
                // DOMContentLoaded, and deleteCollection() dereferences
                // collectionData.name — clicking before that fetch lands throws a
                // TypeError (no dialog) instead of firing confirm(). Wait for the
                // name to swap in from the "Loading..." placeholder, mirroring the
                // waitForSelector guard the subscription tests use before clicking.
                await page.waitForFunction(
                    (name) => document.querySelector('#collection-name')?.textContent?.trim() === name,
                    { timeout: 5000 },
                    fixtureName,
                );
                await page.click('#delete-collection-btn');
                // Native confirm() resolves synchronously inside the click
                // handler, but the dialog event hop costs a tick or two.
                await new Promise(r => setTimeout(r, 300));
            } finally {
                page.off('dialog', onDialog);
            }

            if (!dialog) {
                return { passed: false, message: 'No confirm() dialog fired when clicking #delete-collection-btn' };
            }
            const lowered = dialog.message.toLowerCase();
            const passed = dialog.type === 'confirm' &&
                (lowered.includes('are you sure') || lowered.includes('delete'));
            return {
                passed,
                message: passed
                    ? `confirm() prompt fired: "${dialog.message.slice(0, 60)}..."`
                    : `Dialog fired but contract failed (type=${dialog.type}, message="${dialog.message.slice(0, 80)}")`
            };
        } finally {
            // Best-effort cleanup via the shared helper.
            await deleteCollection(page, collectionId);
        }
    }
};

// ============================================================================
// Subscription CRUD Tests
// ============================================================================
const SubscriptionCrudTests = {
    async createSubscriptionFormOpens(page, baseUrl) {
        // Subscription creation is a page-navigation flow, not a modal.
        // #create-subscription-btn on /news/subscriptions is a plain <button>
        // whose JS handler runs `window.location.href = '/news/subscriptions/new'`
        // (see static/js/pages/subscriptions.js). The previous version of this
        // test relied on findActionButton's click landing on a page with
        // some form and accidentally passed via the navigation, not via an
        // assertion of the real contract.

        await navigateTo(page, `${baseUrl}/news/subscriptions`);
        const hasListButton = await page.evaluate(() => !!document.getElementById('create-subscription-btn'));
        if (!hasListButton) {
            return { passed: false, message: 'create-subscription-btn missing on /news/subscriptions' };
        }

        await navigateTo(page, `${baseUrl}/news/subscriptions/new`);
        const formInfo = await page.evaluate(() => {
            const form = document.querySelector('form:not(#logout-form)');
            const query = document.querySelector('#subscription-query');
            const submit = form?.querySelector('button[type="submit"], input[type="submit"]');
            return {
                hasForm: !!form,
                hasQueryField: !!query,
                queryRequired: !!query?.required,
                queryTag: query?.tagName,
                hasSubmit: !!submit,
                submitText: submit?.textContent?.trim() || submit?.value,
            };
        });

        const passed = formInfo.hasForm && formInfo.hasQueryField && formInfo.hasSubmit;
        return {
            passed,
            message: passed
                ? `Create page form ok (${formInfo.queryTag} #subscription-query required=${formInfo.queryRequired}, submit="${formInfo.submitText}")`
                : `Create page missing parts (form=${formInfo.hasForm}, query=${formInfo.hasQueryField}, submit=${formInfo.hasSubmit})`
        };
    },

    async subscriptionFormValidation(page, baseUrl) {
        // The create form uses HTML5 `required` on the query textarea.
        // Empty submit should leave the textarea :invalid and keep us on
        // /news/subscriptions/new (no POST). We assert the browser-level
        // contract rather than scraping app-error selectors.

        await navigateTo(page, `${baseUrl}/news/subscriptions/new`);

        const result = await page.evaluate(() => {
            const form = document.querySelector('form:not(#logout-form)');
            const submit = form?.querySelector('button[type="submit"], input[type="submit"]');
            if (!form || !submit) return { hasForm: false };

            // Make sure query is empty, then submit.
            const query = document.querySelector('#subscription-query');
            if (query) query.value = '';
            submit.click();

            return new Promise(resolve => setTimeout(() => {
                const queryEl = document.querySelector('#subscription-query');
                resolve({
                    hasForm: true,
                    requiredInvalid: queryEl ? !queryEl.checkValidity() : null,
                    stayedOnPage: location.pathname.endsWith('/news/subscriptions/new'),
                });
            }, 300));
        });

        if (!result.hasForm) {
            return { passed: false, message: 'Subscription form not found on /news/subscriptions/new' };
        }

        const passed = result.requiredInvalid === true && result.stayedOnPage;
        return {
            passed,
            message: passed
                ? 'Empty submit blocked by HTML5 :invalid on required query textarea'
                : `Validation contract failed (requiredInvalid=${result.requiredInvalid}, stayedOnPage=${result.stayedOnPage})`
        };
    },

    async subscriptionStrategyOptions(page, baseUrl) {
        // The original test (subscriptionTypeOptions) looked for a generic
        // "type" / "category" dropdown that does not exist on the real
        // /news/subscriptions/new form. The closest equivalent is the
        // research-strategy <select id="subscription-strategy">, which is
        // a real dropdown with multiple options. Re-target the assertion
        // there so it exercises a real contract instead of silently
        // SKIPping.
        //
        // The sibling subscriptionFrequencyOptions test was removed at the
        // same time — the form has no scheduling / frequency input at all
        // (only iteration / question counts), so there was nothing for it
        // to point at.

        await navigateTo(page, `${baseUrl}/news/subscriptions/new`);

        const result = await page.evaluate(() => {
            const sel = document.querySelector('#subscription-strategy');
            if (!sel) return { exists: false };
            // The template renders <option value="{{ s.name }}">{{ s.label }}</option>
            // from the server-side AVAILABLE_STRATEGIES constant (constants.py),
            // so the option *values* are stable strategy ids regardless of LLM.
            const values = Array.from(sel.options).map(o => o.value);
            return {
                exists: true,
                optionCount: values.length,
                values,
            };
        });

        if (!result.exists) {
            return { passed: false, message: '#subscription-strategy select missing on /news/subscriptions/new' };
        }
        // Assert a real contract, not merely "non-empty": the strategy dropdown
        // must offer at least two choices and include at least one known
        // strategy id from constants.py::AVAILABLE_STRATEGIES. KNOWN_STRATEGIES
        // is parsed from constants.py at module load (see top of file).
        const matched = KNOWN_STRATEGIES.filter(s => result.values.includes(s));
        const passed = result.optionCount >= 2 && matched.length > 0;
        return {
            passed,
            message: passed
                ? `Strategy dropdown ok (${result.optionCount} options, known: ${matched.join(', ')})`
                : `Strategy dropdown contract failed (count=${result.optionCount}, values=[${result.values.slice(0, 6).join(', ')}], knownMatched=${matched.length})`
        };
    },

    async subscriptionToggleStatus(page, baseUrl) {
        // The old version scanned the listing for a button matching
        // `class*="toggle"` / `class*="pause"` / `class*="resume"` /
        // `.toggle-status` / `input[type=checkbox]`. The real card markup
        // (pages/subscriptions.js::renderSubscriptionCard) uses
        // `<button class="btn btn-sm ldr-btn-icon" title="Pause|Resume">`
        // — none of the old selectors match. Combined with an empty test
        // DB, the test was a permanent SKIP for a real product feature.
        //
        // Seed an *active* subscription via the API, click the Pause
        // toggle, and assert the status flips to "paused" (both the badge
        // text and the button's title attribute). This exercises the
        // actual pause/resume contract.

        await navigateTo(page, `${baseUrl}/news/subscriptions`);

        const seed = await page.evaluate(async () => {
            const csrf = document.querySelector('meta[name="csrf-token"]')?.content;
            const r = await fetch('/news/api/subscribe', {
                method: 'POST',
                credentials: 'same-origin',
                headers: { 'Content-Type': 'application/json', 'X-CSRFToken': csrf || '' },
                body: JSON.stringify({
                    query: `ldr-ui-test-toggle-${Date.now()}`,
                    subscription_type: 'search',
                    is_active: true,
                }),
            });
            return { ok: r.ok, status: r.status, body: await r.json().catch(() => ({})) };
        });
        const subId = seed.body?.subscription_id;
        if (!seed.ok || !subId) {
            return { passed: false, message: `Could not seed subscription (status=${seed.status}, body=${JSON.stringify(seed.body).slice(0, 120)})` };
        }

        try {
            // navigateTo no-ops when already on the same path, so use
            // page.reload() to force renderSubscriptions to pick up the
            // freshly-seeded card. (Same applies to the delete test below.)
            await page.reload({ waitUntil: 'domcontentloaded' });
            // Card insertion is async — give the page tick(s) to fetch + render.
            await page.waitForSelector(`[data-subscription-id="${subId}"]`, { timeout: 5000 });

            const before = await page.evaluate((id) => {
                const card = document.querySelector(`[data-subscription-id="${id}"]`);
                const toggle = card?.querySelector('button[title="Pause"], button[title="Resume"]');
                const badge = card?.querySelector('.ldr-status-badge');
                return {
                    toggleTitle: toggle?.title,
                    status: badge?.textContent?.trim(),
                };
            }, subId);
            if (before.toggleTitle !== 'Pause' || before.status !== 'active') {
                return { passed: false, message: `Seeded card not in expected active state (toggle="${before.toggleTitle}", badge="${before.status}")` };
            }

            await page.click(`[data-subscription-id="${subId}"] button[title="Pause"]`);
            // The toggle round-trips to PUT /news/api/subscriptions/<id>
            // (toggleSubscription) and re-renders the list in place. Wait
            // for the button's title to flip rather than racing a fixed
            // setTimeout.
            await page.waitForFunction((id) => {
                const card = document.querySelector(`[data-subscription-id="${id}"]`);
                return card?.querySelector('button[title="Resume"]') != null;
            }, { timeout: 5000 }, subId);

            const after = await page.evaluate((id) => {
                const card = document.querySelector(`[data-subscription-id="${id}"]`);
                const toggle = card?.querySelector('button[title="Pause"], button[title="Resume"]');
                const badge = card?.querySelector('.ldr-status-badge');
                return {
                    toggleTitle: toggle?.title,
                    status: badge?.textContent?.trim(),
                };
            }, subId);

            const flipped = after.toggleTitle === 'Resume' && after.status === 'paused';
            return {
                passed: flipped,
                message: flipped
                    ? `Pause → Resume toggle works (active → paused, button retitled)`
                    : `Toggle did not flip state (toggle="${after.toggleTitle}", badge="${after.status}")`
            };
        } catch (err) {
            return { passed: false, message: `Toggle test threw: ${err.message?.slice(0, 100)}` };
        } finally {
            // Best-effort cleanup — wrap the page.evaluate itself so a
            // torn-down page during cleanup can never throw and mask the
            // real test result.
            try {
                await page.evaluate(async (id) => {
                    const csrf = document.querySelector('meta[name="csrf-token"]')?.content;
                    try {
                        await fetch(`/news/api/subscriptions/${id}`, {
                            method: 'DELETE',
                            credentials: 'same-origin',
                            headers: { 'X-CSRFToken': csrf || '' },
                        });
                    } catch { /* swallow fetch errors */ }
                }, subId);
            } catch { /* swallow page.evaluate errors so cleanup never masks the test result */ }
        }
    },

    async subscriptionDeleteConfirmation(page, baseUrl) {
        // Same shape as the collection delete rewrite (#4174). The card
        // delete button (.btn-danger inside the card) calls
        // deleteSubscriptionDirect, which guards on a *native*
        // window.confirm() — not a DOM modal. The old `.modal,
        // .confirm-dialog, [role="alertdialog"]` query never matched
        // anything even on a populated list. Seed via the API, click
        // delete, capture the dialog, dismiss to keep the fixture for
        // explicit cleanup.

        await navigateTo(page, `${baseUrl}/news/subscriptions`);

        const seed = await page.evaluate(async () => {
            const csrf = document.querySelector('meta[name="csrf-token"]')?.content;
            const r = await fetch('/news/api/subscribe', {
                method: 'POST',
                credentials: 'same-origin',
                headers: { 'Content-Type': 'application/json', 'X-CSRFToken': csrf || '' },
                body: JSON.stringify({
                    query: `ldr-ui-test-delete-${Date.now()}`,
                    subscription_type: 'search',
                    is_active: false,
                }),
            });
            return { ok: r.ok, status: r.status, body: await r.json().catch(() => ({})) };
        });
        const subId = seed.body?.subscription_id;
        if (!seed.ok || !subId) {
            return { passed: false, message: `Could not seed subscription (status=${seed.status}, body=${JSON.stringify(seed.body).slice(0, 120)})` };
        }

        try {
            await page.reload({ waitUntil: 'domcontentloaded' });
            await page.waitForSelector(`[data-subscription-id="${subId}"] .btn-danger`, { timeout: 5000 });

            let dialog = null;
            const onDialog = async d => {
                dialog = { type: d.type(), message: d.message() };
                await d.dismiss();
            };
            page.on('dialog', onDialog);

            try {
                await page.click(`[data-subscription-id="${subId}"] .btn-danger`);
                await new Promise(r => setTimeout(r, 300));
            } finally {
                page.off('dialog', onDialog);
            }

            if (!dialog) {
                return { passed: false, message: 'No confirm() dialog fired when clicking subscription delete' };
            }
            const lowered = dialog.message.toLowerCase();
            const passed = dialog.type === 'confirm' &&
                (lowered.includes('are you sure') || lowered.includes('delete'));
            return {
                passed,
                message: passed
                    ? `confirm() prompt fired: "${dialog.message.slice(0, 60)}..."`
                    : `Dialog fired but contract failed (type=${dialog.type}, message="${dialog.message.slice(0, 80)}")`
            };
        } finally {
            // Best-effort cleanup — wrap the page.evaluate itself so a
            // torn-down page during cleanup can never throw and mask the
            // real test result.
            try {
                await page.evaluate(async (id) => {
                    const csrf = document.querySelector('meta[name="csrf-token"]')?.content;
                    try {
                        await fetch(`/news/api/subscriptions/${id}`, {
                            method: 'DELETE',
                            credentials: 'same-origin',
                            headers: { 'X-CSRFToken': csrf || '' },
                        });
                    } catch { /* swallow fetch errors */ }
                }, subId);
            } catch { /* swallow page.evaluate errors so cleanup never masks the test result */ }
        }
    }
};

// ============================================================================
// Document CRUD Tests
// ============================================================================
const DocumentCrudTests = {
    async documentUploadFormExists(page, baseUrl) {
        // The previous version checked /library for an "upload" button or
        // file input. That page is the *research-library* index (filter +
        // results), not an upload form — it never had upload UI, so the
        // test was a permanent SKIP. Direct upload lives at
        // /library/collections/<id>/upload (rag_routes.py).
        //
        // Seed a throwaway collection so the upload page is reachable on
        // a fresh DB, navigate to it, and assert the form exists with a
        // multiple-file input and the submit affordance. Clean up the
        // collection afterwards.

        // Land on an app page first so seedCollection / uploadFixtureDocument
        // can read the CSRF meta tag, rather than depending on the page state
        // the previous test happened to leave behind.
        await navigateTo(page, `${baseUrl}/library/collections`);
        const collId = (await seedCollection(page))?.id;
        if (!collId) {
            return { passed: false, message: 'Could not seed collection for upload form test' };
        }
        try {
            await navigateTo(page, `${baseUrl}/library/collections/${collId}/upload`);
            const result = await page.evaluate(() => {
                const form = document.querySelector('form#upload-files-form, form[enctype*="multipart"]');
                const fileInput = document.querySelector('input[type="file"]');
                const submit = document.querySelector('button[type="submit"], input[type="submit"]');
                return {
                    hasForm: !!form,
                    hasFileInput: !!fileInput,
                    fileInputMultiple: !!fileInput?.multiple,
                    fileInputAccept: fileInput?.accept || '',
                    hasSubmit: !!submit,
                    submitText: submit?.textContent?.trim() || submit?.value || '',
                };
            });
            const passed = result.hasForm && result.hasFileInput && result.hasSubmit;
            return {
                passed,
                message: passed
                    ? `Upload form ok (file input multiple=${result.fileInputMultiple}, accept includes ${result.fileInputAccept.split(',').length} types, submit="${result.submitText.slice(0, 30)}")`
                    : `Upload form missing parts (form=${result.hasForm}, file=${result.hasFileInput}, submit=${result.hasSubmit})`,
            };
        } finally {
            await deleteCollection(page, collId);
        }
    },

    async documentDeleteConfirmation(page, baseUrl) {
        // The list-page document rows use DeleteManager.deleteDocument
        // (deletion/delete_manager.js), which shows the Bootstrap
        // #deleteConfirmModal (components/delete_confirmation_modal.html).
        // The old test scanned for generic doc-row selectors that never
        // matched and SKIPped on empty DB.
        //
        // Pipeline: seed a collection, upload a tiny text fixture via the
        // multipart API (the real upload path), navigate to
        // /library/?collection=<id> so only our fixture row is in scope,
        // click .ldr-btn-delete-doc, and assert #deleteConfirmModal goes
        // from hidden to visible. Bootstrap drives the .show class via
        // its Modal API, so we wait on that rather than racing a fixed
        // timeout.

        // Land on an app page first so seedCollection / uploadFixtureDocument
        // can read the CSRF meta tag, rather than depending on the page state
        // the previous test happened to leave behind.
        await navigateTo(page, `${baseUrl}/library/collections`);
        const collId = (await seedCollection(page))?.id;
        if (!collId) {
            return { passed: false, message: 'Could not seed collection for delete test' };
        }
        try {
            const upload = await uploadFixtureDocument(page, collId);
            if (!upload.ok) {
                return { passed: false, message: `Could not upload fixture document (status=${upload.status})` };
            }

            await navigateTo(page, `${baseUrl}/library/?collection=${collId}`);
            await page.waitForSelector('.ldr-btn-delete-doc', { timeout: 5000 });

            // Pre-flight: the bootstrap modal node is always present in
            // the include; only its .show class flips when invoked.
            const preState = await page.evaluate(() => {
                const m = document.getElementById('deleteConfirmModal');
                return { exists: !!m, visible: m?.classList.contains('show') ?? null };
            });
            if (!preState.exists) {
                return { passed: false, message: '#deleteConfirmModal not in DOM — components/delete_confirmation_modal.html include broken?' };
            }

            await page.click('.ldr-btn-delete-doc');
            // Wait for Bootstrap to add the .show class.
            await page.waitForFunction(
                () => document.getElementById('deleteConfirmModal')?.classList.contains('show') === true,
                { timeout: 5000 }
            );

            const postState = await page.evaluate(() => {
                const m = document.getElementById('deleteConfirmModal');
                return {
                    visible: m?.classList.contains('show'),
                    title: document.getElementById('deleteConfirmModalLabel')?.textContent?.trim().slice(0, 50),
                };
            });
            return {
                passed: postState.visible === true,
                message: postState.visible
                    ? `#deleteConfirmModal opens on document delete click (title="${postState.title}")`
                    : `Modal did not become visible (post.visible=${postState.visible})`,
            };
        } finally {
            await deleteCollection(page, collId);
        }
    }

    // NOTE: the former bulkDeleteSelection test was removed (not skipped).
    // The /library document listing (templates/pages/library.html) renders
    // server-side document cards with only per-card delete buttons
    // (.ldr-btn-delete-blob / .ldr-btn-delete-doc) — there are NO per-row
    // checkboxes, no select-all control, and no bulk-action button anywhere
    // on that view. The bulkDeleteDocuments() helper in
    // static/js/deletion/delete_manager.js takes an explicit id array and is
    // never wired to a selection surface on this page. The old test therefore
    // SKIPped on the main path for a feature that does not exist here; per the
    // dead-field audit we delete it rather than carry a permanent skip.
};

// ============================================================================
// Shared fixture helpers for the collection + document CRUD tests.
//
// seedCollection/deleteCollection back both collectionDeleteConfirmation and
// the document tests (which need a collection to upload into). Cleanup wraps
// page.evaluate in a Node-side try/catch so a torn-down page during teardown
// can never throw and mask a test result. Kept in this file (not test_lib/)
// while the per-test seed pattern is still proving out across PRs #4174 /
// #4180 / this one; graduates to test_lib/ once a second test file needs it.
// ============================================================================

async function seedCollection(page) {
    const r = await page.evaluate(async () => {
        const csrf = document.querySelector('meta[name="csrf-token"]')?.content;
        const name = `ldr-ui-test-collection-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
        const res = await fetch('/library/api/collections', {
            method: 'POST',
            credentials: 'same-origin',
            headers: { 'Content-Type': 'application/json', 'X-CSRFToken': csrf || '' },
            body: JSON.stringify({ name, description: 'UI test fixture', type: 'user_uploads' }),
        });
        const body = await res.json().catch(() => ({}));
        return { ok: res.ok, name, success: body?.success === true, id: body?.collection?.id };
    });
    return r.ok && r.success && r.id ? { id: r.id, name: r.name } : null;
}

async function deleteCollection(page, collectionId) {
    if (!collectionId) return;
    // Wrap the page.evaluate itself so a torn-down page during teardown can
    // never throw and mask the real test result (matches the #4174 guard).
    try {
        await page.evaluate(async (id) => {
            const csrf = document.querySelector('meta[name="csrf-token"]')?.content;
            try {
                await fetch(`/library/api/collections/${id}`, {
                    method: 'DELETE',
                    credentials: 'same-origin',
                    headers: { 'X-CSRFToken': csrf || '' },
                });
            } catch { /* swallow fetch errors */ }
        }, collectionId);
    } catch { /* swallow page.evaluate errors so cleanup never masks the test result */ }
}

async function uploadFixtureDocument(page, collectionId) {
    return await page.evaluate(async (id) => {
        const csrf = document.querySelector('meta[name="csrf-token"]')?.content;
        const file = new File(
            [new Blob(['UI test fixture document\n'], { type: 'text/plain' })],
            'ldr-ui-test.txt',
            { type: 'text/plain' }
        );
        const fd = new FormData();
        fd.append('files', file);
        fd.append('storage_mode', 'database');
        const r = await fetch(`/library/api/collections/${id}/upload`, {
            method: 'POST',
            credentials: 'same-origin',
            headers: { 'X-CSRFToken': csrf || '' },
            body: fd,
        });
        return { ok: r.ok, status: r.status };
    }, collectionId);
}

// ============================================================================
// Main Test Runner
// ============================================================================
async function main() {
    log.section('CRUD Operations Tests');

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

    const subTestTimeout = ctx.config.isCI ? 60000 : 30000;
    async function run(category, name, testFn) {
        try {
            const result = await withTimeout(
                testFn(page, baseUrl),
                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 {
        // Collection CRUD Tests
        log.section('Collection CRUD');
        await run('Collections', 'Create Collection Form Opens', (p, u) => CollectionCrudTests.createCollectionFormOpens(p, u));
        await run('Collections', 'Create Collection Form Validation', (p, u) => CollectionCrudTests.createCollectionFormValidation(p, u));
        await run('Collections', 'Collection Delete Confirmation', (p, u) => CollectionCrudTests.collectionDeleteConfirmation(p, u));

        // Subscription CRUD Tests
        log.section('Subscription CRUD');
        await run('Subscriptions', 'Create Subscription Form Opens', (p, u) => SubscriptionCrudTests.createSubscriptionFormOpens(p, u));
        await run('Subscriptions', 'Subscription Form Validation', (p, u) => SubscriptionCrudTests.subscriptionFormValidation(p, u));
        await run('Subscriptions', 'Subscription Strategy Options', (p, u) => SubscriptionCrudTests.subscriptionStrategyOptions(p, u));
        await run('Subscriptions', 'Subscription Toggle Status', (p, u) => SubscriptionCrudTests.subscriptionToggleStatus(p, u));
        await run('Subscriptions', 'Subscription Delete Confirmation', (p, u) => SubscriptionCrudTests.subscriptionDeleteConfirmation(p, u));

        // Document CRUD Tests
        log.section('Document CRUD');
        await run('Documents', 'Document Upload Form Exists', (p, u) => DocumentCrudTests.documentUploadFormExists(p, u));
        await run('Documents', 'Document Delete Confirmation', (p, u) => DocumentCrudTests.documentDeleteConfirmation(p, u));

    } catch (error) {
        log.error(`Fatal error: ${error.message}`);
        console.error(error.stack);
    } finally {
        results.print();
        results.save();
        await teardownTest(ctx);
        process.exit(results.exitCode());
    }
}

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

module.exports = { CollectionCrudTests, SubscriptionCrudTests, DocumentCrudTests };
