import {expect, test} from "@playwright/test";
import {login} from "../helpers/auth.mjs";

test.describe.serial("Scan flow across WebUI tabs", () => {
    let page = null;
    let scannedSubjects = [];
    const scannedBefore = {scanTab: 0, throughput: 0};

    async function gotoTab(name) {
        await page.locator(`#${name}_nav`).click();
    }

    function extractNumber(text) {
        return parseInt(text.replace(/\D/g, ""), 10);
    }

    async function readScanTab(skipNavigation = false) {
        // Status tab → scanned widget
        if (!skipNavigation) {
            await gotoTab("status");
            await page.waitForResponse((resp) => resp.url().includes("/stat") && resp.status() === 200);
        }
        const scannedWidget = page.locator("#statWidgets .widget[title*='scanned']");
        await expect(scannedWidget).toBeVisible();
        const scannedTitle = await scannedWidget.getAttribute("title");
        return extractNumber(scannedTitle);
    }

    async function readThroughput() {
        // Throughput tab → id="rrd-total-value"
        await gotoTab("throughput");
        await page.waitForResponse((resp) => resp.url().includes("/graph") && resp.status() === 200);
        const throughputValue = page.locator("#rrd-total-value");
        await expect(throughputValue).toBeVisible();
        return extractNumber(await throughputValue.textContent());
    }

    async function expectAlertSuccess(expectedText) {
        // Wait for a new alert to appear by counting existing alerts first
        const initialAlertCount = await page.locator(".alert-success, .alert-modal.alert-success").count();

        // Wait for a new alert to appear
        await expect(async () => {
            const currentCount = await page.locator(".alert-success, .alert-modal.alert-success").count();
            return currentCount > initialAlertCount;
        }).toPass({timeout: 5000});

        // Get the most recently appeared alert (last one)
        const alert = page.locator(".alert-success, .alert-modal.alert-success").last();
        await expect(alert).toBeVisible();
        const text = await alert.textContent();
        expect(text).toContain(expectedText);
        await expect(alert).not.toBeVisible({timeout: 10000});
    }

    /**
     * Test symbol ordering functionality
     * @param {string} table - Table name ("scan" or "history")
     * @param {boolean} needsExpand - Whether row needs to be expanded
     */
    async function testSymbolOrdering(table, needsExpand) {
        const tableLocator = page.locator(`#historyTable_${table} .tabulator-table`);
        const firstRow = tableLocator.locator(".tabulator-row").first();
        await expect(firstRow).toBeVisible();

        // Tabulator renders the responsive-collapse detail as a descendant of the
        // row element (.tabulator-responsive-collapse).
        // Only expand when collapsed: a prior test may have left the row open, and a
        // blind click would collapse it again.
        const detailRow = firstRow.locator(".tabulator-responsive-collapse");
        if (needsExpand && !(await detailRow.isVisible())) {
            await firstRow.click();
        }
        await expect(detailRow).toBeVisible();

        // The collapse lays each hidden column out as
        // <tr><td><strong>TITLE</strong></td><td>VALUE</td></tr>. The symbols column
        // title embeds the .sym-order-toggle sort buttons, and the symbol HTML is the
        // value cell (the second td in that row).
        const symbolsRow = detailRow.locator("tr:has(.sym-order-toggle)");
        const symbolsCell = symbolsRow.locator("td").nth(1);

        // Get initial symbols order
        let previousSymbols = await symbolsCell.innerHTML();

        // Use the dropdown selector to change symbols order
        const selector = page.locator(`#selSymOrder_${table}`);
        await expect(selector).toBeVisible();

        // Test each sort order in sequence (initial order is "magnitude")
        for (const order of ["name", "score"]) {
            await selector.selectOption(order);
            await expect.poll(async () => await symbolsCell.innerHTML(), {timeout: 3000}).not.toBe(previousSymbols);
            previousSymbols = await symbolsCell.innerHTML();
        }

        // Verify no JavaScript errors occurred (especially the regression bug)
        const errors = [];
        page.on("pageerror", (err) => errors.push(err.message));
        await page.waitForTimeout(500);
        expect(errors).toHaveLength(0);
    }

    test.beforeAll(async ({browser}, testInfo) => {
        const context = await browser.newContext();
        page = await context.newPage();
        const {enablePassword} = testInfo.project.use.rspamdPasswords;
        await login(page, enablePassword);
    });

    test.afterAll(async () => {
        await page.close();
    });

    test.describe("Phase 1: before scanning", () => {
        test("Read current Scanned counters", async () => {
            scannedBefore.scanTab = await readScanTab(true);
            scannedBefore.throughput = await readThroughput();
        });
    });

    test.describe("Phase 2: scanning", () => {
        test("Scan two test messages", async ({}, testInfo) => {
            const {testId} = testInfo;
            await gotoTab("scan");
            const scanMessageBtn = page.locator('#scan button[data-upload="checkv2"]');
            await expect(scanMessageBtn).toBeDisabled();

            scannedSubjects = [];

            for (let i = 1; i <= 2; i++) {
                const timestamp = Date.now();
                const msgId = `E2E-${i}-${timestamp}@example.com`;
                const subject = `E2E Test ${i} ${testId}-${timestamp}`;
                scannedSubjects.push(subject);

                await page.locator("#scanMsgSource").fill(
                    `Message-Id: ${msgId}\nFrom: test@example.com\nSubject: ${subject}\n\nTest body`
                );
                await scanMessageBtn.click();

                await expectAlertSuccess("Data successfully scanned");
                await expect(
                    page.locator("#historyTable_scan .tabulator-row").first()
                        .locator('.tabulator-cell[tabulator-field="id"]')
                ).toContainText(msgId);
            }
        });

        test("Symbol ordering works on Scan tab", async () => {
            await testSymbolOrdering("scan", false);
        });
    });

    test.describe("Phase 3: after scanning", () => {
        test("History shows scanned messages", async () => {
            await gotoTab("history");

            // Check both scanned messages are present in reverse order
            for (let i = 0; i < scannedSubjects.length; i++) {
                const subject = scannedSubjects[scannedSubjects.length - 1 - i]; // reverse order
                const row = page.locator("#historyTable_history .tabulator-row").nth(i);
                // The Subject column can collapse into the detail row depending on
                // viewport width, so expand the row and match against its full text.
                await row.click();
                await expect(row).toContainText(subject);
            }
        });

        test("Symbol ordering works on History tab", async () => {
            await testSymbolOrdering("history", true);
        });

        test("History can be reset", async () => {
            // Reset history
            const resetBtn = page.locator("#resetHistory");
            await expect(resetBtn).toBeVisible();
            page.once("dialog", (dialog) => dialog.accept());
            // The reload disables #updateHistory only for the duration of the
            // fast, local history request, which is too brief to assert on the
            // DOM. Wait for the reset and the subsequent reload responses instead.
            // Listeners must be registered BEFORE the click: waitForResponse only
            // catches responses arriving after setup, and the local historyreset
            // round-trip can otherwise beat a listener attached post-click.
            await Promise.all([
                page.waitForResponse((r) => r.url().includes("historyreset"), {timeout: 10000}),
                page.waitForResponse(
                    (r) => r.url().includes("history") && r.url().includes("from="),
                    {timeout: 10000}
                ),
                resetBtn.click(),
            ]);

            const updateHistoryBtn = page.locator("#updateHistory");
            await expect(updateHistoryBtn).toBeEnabled();

            const rows = await page.locator("#historyTable_history .tabulator-row").count();
            // Known bug: Rspamd leaves one row after history reset
            expect([0, 1]).toContain(rows);
        });
    });

    test.describe("Phase 4: counters after scanning", () => {
        test("Status tab `Scanned` counter increased by 2", async () => {
            const scanTab = await readScanTab();
            expect(scanTab).toBe(scannedBefore.scanTab + 2);
        });

        test("Throughput `Total messages` counter increased", async ({}, testInfo) => {
            testInfo.setTimeout(140000);
            // #rrd-total-value is a rate integral (truncated per series, summed
            // over the 6 action series, with a msg/s↔msg/min scaleFactor switch)
            // — an approximation. In the small-data regime (fresh RRD) it
            // overshoots variably (CI saw up to ~3x the scan count: +6 for 2
            // scans), so pin a wide band, not an exact value: it must rise above
            // the baseline (regression: scans not reflected) yet stay below a
            // generous ceiling that a gross normalization bug (dropped scaleFactor
            // /unit divisor) would blow through.
            const ceiling = scannedBefore.throughput + 50; // ~25x the 2 scans
            let lastValue = null;
            try {
                await expect.poll(async () => {
                    lastValue = await readThroughput();
                    return lastValue > scannedBefore.throughput && lastValue <= ceiling;
                }, {
                    interval: 5000,
                    // step = 1s, pdp_per_row = 60 → next row every 60s
                    timeout: 125000,
                }).toBeTruthy();
            } catch (e) {
                const msg = `Throughput counter should be > ${scannedBefore.throughput} and <= ${ceiling}, got ${lastValue}`;
                throw new Error(msg, {cause: e});
            }
        });
    });

    test.describe("Regression: classifier list after RO → disconnect → enable", () => {
        test("Classifier dropdown is populated after reconnect", async ({browser}, testInfo) => {
            const {readOnlyPassword, enablePassword} = testInfo.project.use.rspamdPasswords;

            // Use isolated context to avoid pre-populated state from other tests
            const context = await browser.newContext();
            const page2 = await context.newPage();

            async function gotoTabLocal(name) {
                await page2.locator(`#${name}_nav`).click();
            }

            // Login as read-only
            await login(page2, readOnlyPassword);
            await page2.waitForSelector("#navBar:not(.d-none)");
            // Bootstrap dismisses #connectDialog asynchronously (fade transition).
            // navBar loses d-none from the /auth "complete" callback, which can fire
            // before the modal finishes closing — the lingering .show then intercepts
            // nav clicks (seen on WebKit under CI load).
            await expect(page2.locator("#connectDialog")).toBeHidden({timeout: 10000});

            // Go to Scan in RO
            await gotoTabLocal("scan");
            await expect(page2.locator("#scan")).toBeVisible();

            // Disconnect and login as enable (writable)
            await page2.locator("#disconnect").click();
            // Avoid shared login() helper which calls page.goto('/') to stay on the same tab.
            await page2.locator("#connectPassword").fill(enablePassword);
            // Expect classifiers request after successful enable login
            const p = page2.waitForResponse(
                (r) => r.url().includes("/bayes/classifiers") && r.status() === 200,
                {timeout: 5000}
            );
            await page2.locator("#connectButton").click();
            await page2.waitForSelector("#navBar:not(.d-none)");
            await expect(page2.locator("#connectDialog")).toBeHidden({timeout: 10000});
            await p;

            // Expect classifiers to be populated
            const classifier = page2.locator("#classifier");
            await expect(classifier).toBeVisible();
            const optionCount = await classifier.locator("option").count();
            expect(optionCount).toBeGreaterThan(1);

            // Verify that a subsequent getClassifiers call under the same config is skipped
            let reqCount = 0;
            function reqListener(r) {
                if (r.url().includes("/bayes/classifiers")) {
                    reqCount += 1;
                }
            }
            page2.on("request", reqListener);

            await page2.evaluate(() => new Promise((resolve) => {
                // AMD require is available in the UI
                // eslint-disable-next-line no-undef
                require(["app/upload"], (u) => {
                    u.getClassifiers();
                    resolve();
                });
            }));

            await page2.waitForTimeout(250);
            page2.off("request", reqListener);
            expect(reqCount).toBe(0);

            // Options must remain populated (not reset to just default)
            const finalCount = await classifier.locator("option").count();
            expect(finalCount).toBeGreaterThan(1);

            await page2.close();
            await context.close();
        });
    });
});
