import { execFile } from "node:child_process";
import { randomBytes } from "node:crypto";
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { dirname, join } from "node:path";
import { promisify } from "node:util";
import { VER } from "@vrtmrz/livesync-commonlib/compat/common/types";
import type { Locator, Page } from "playwright";
import { evalObsidianJson } from "../runner/cli.ts";
import {
    assertCouchDbReachable,
    deleteCouchDbDatabase,
    loadCouchDbConfig,
    makeUniqueDatabaseName,
    waitForCouchDbDocs,
    type CouchDbConfig,
} from "../runner/couchdb.ts";
import { discoverObsidianCli, requireObsidianBinary } from "../runner/environment.ts";
import {
    assertEqual,
    pushLocalChanges,
    waitForLocalDatabaseEntry,
    type LocalDatabaseEntry,
} from "../runner/liveSyncWorkflow.ts";
import { startObsidianLiveSyncSession, type ObsidianLiveSyncSession } from "../runner/session.ts";
import {
    assertVerticalActionLayout,
    generateSetupURIFromDevice,
    resumeCompatibilityReviewIfShown,
} from "../runner/setupUri.ts";
import {
    captureObsidianDialogue,
    captureObsidianElement,
    captureObsidianPage,
    openLiveSyncSettings,
    withObsidianPage,
} from "../runner/ui.ts";
import { createTemporaryVault, type TemporaryVault } from "../runner/vault.ts";

process.env.E2E_OBSIDIAN_CLI_TIMEOUT_MS ??= "90000";
process.env.E2E_OBSIDIAN_COUCHDB_TIMEOUT_MS ??= "30000";

const execFileAsync = promisify(execFile);
const uiTimeoutMs = Number(process.env.E2E_OBSIDIAN_SETUP_URI_TIMEOUT_MS ?? 30000);
const initialisationTimeoutMs = Number(process.env.E2E_OBSIDIAN_SETUP_INITIALISATION_TIMEOUT_MS ?? 120000);
const hiddenFileCliTimeoutMs = Number(process.env.E2E_OBSIDIAN_HIDDEN_FILE_CLI_TIMEOUT_MS ?? 90000);
const notePath = "E2E/setup-uri/provisioned-workflow.md";
const noteContent = "# Provisioned Setup URI\n\nThis note travelled through the generated CouchDB Setup URI.\n";
const returnNotePath = "E2E/setup-uri/from-second-device.md";
const returnNoteContent =
    "# CouchDB from the second device\n\nThis note completed the return journey through CouchDB.\n";
const snippetPath = ".obsidian/snippets/setup-uri-workflow.css";
const snippetContent = [
    "body {",
    "    --setup-uri-workflow-colour: #245a70;",
    "}",
    "",
    ".setup-uri-workflow {",
    "    color: var(--setup-uri-workflow-colour);",
    "}",
    "",
].join("\n");

type SetupArtifact = {
    setupURI: string;
    setupPassphrase: string;
};

type SetupState = {
    configured: boolean;
    databaseReady: boolean;
    appReady: boolean;
    suspended: boolean;
    remoteType: string;
    activeConfigurationId: string;
    remoteConfigurationCount: number;
    syncInternalFiles: boolean;
    syncInternalFilesBeforeReplication: boolean;
};

type RunnerContext = {
    binary: string;
    cliBinary: string;
    couchDb: CouchDbConfig;
    dbName: string;
    activeSessions: Set<ObsidianLiveSyncSession>;
};

function modalByTitle(page: Page, title: string): Locator {
    return page.locator(".modal-container").filter({
        has: page.locator(".modal-title").filter({ hasText: title }),
    });
}

async function liveSyncSettingPanelByTitle(page: Page, pageName: string, title: string): Promise<Locator> {
    const settingsNavigator = await openLiveSyncSettings(page, uiTimeoutMs);
    const settingsPage = await settingsNavigator.openPage(pageName);
    return settingsPage.locator("h4.sls-setting-panel-title:visible").filter({ hasText: title }).locator("..");
}

async function captureGuideDialogue(port: number, filename: string, title: string): Promise<string> {
    return await captureObsidianElement(port, filename, (page) => modalByTitle(page, title).locator(".modal").first());
}

async function selectRadioOption(modal: Locator, title: string): Promise<void> {
    const radio = modal.locator("label").filter({ hasText: title }).locator('input[type="radio"]').first();
    await radio.check({ timeout: uiTimeoutMs });
}

async function selectCheckbox(modal: Locator, title: string): Promise<void> {
    const checkbox = modal.locator("label").filter({ hasText: title }).locator('input[type="checkbox"]').first();
    await checkbox.check({ timeout: uiTimeoutMs });
}

async function writeVaultFile(vaultPath: string, path: string, content: string): Promise<void> {
    const fullPath = join(vaultPath, path);
    await mkdir(dirname(fullPath), { recursive: true });
    await writeFile(fullPath, content, "utf8");
}

async function readVaultFile(vaultPath: string, path: string): Promise<string> {
    return await readFile(join(vaultPath, path), "utf8");
}

async function waitForPathContent(
    vaultPath: string,
    path: string,
    expected: string,
    timeoutMs = Number(process.env.E2E_OBSIDIAN_FILE_TIMEOUT_MS ?? 30000)
): Promise<string> {
    const deadline = Date.now() + timeoutMs;
    let lastContent = "";
    while (Date.now() < deadline) {
        try {
            lastContent = await readVaultFile(vaultPath, path);
            if (lastContent === expected) return lastContent;
        } catch (error) {
            if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
        }
        await new Promise((resolve) => setTimeout(resolve, 250));
    }
    throw new Error(`Timed out waiting for ${path}. Last content:\n${lastContent}`);
}

async function runDeno(script: string, permissions: string[], environment: NodeJS.ProcessEnv): Promise<string> {
    const { stdout } = await execFileAsync(
        "deno",
        [
            "run",
            "--minimum-dependency-age=0",
            "--config=utils/flyio/deno.jsonc",
            "--frozen",
            "--lock=utils/flyio/deno.lock",
            ...permissions,
            script,
        ],
        {
            cwd: process.cwd(),
            env: environment,
            maxBuffer: 4 * 1024 * 1024,
        }
    );
    return stdout;
}

async function provisionAndGenerateSetupURI(couchDb: CouchDbConfig, dbName: string): Promise<SetupArtifact> {
    const setupPassphrase = randomBytes(24).toString("base64url");
    const environment = {
        ...process.env,
        hostname: couchDb.uri,
        username: couchDb.username,
        password: couchDb.password,
        database: dbName,
        passphrase: randomBytes(24).toString("base64url"),
        uri_passphrase: setupPassphrase,
        remote_type: "couchdb",
        retry_count: "3",
        retry_delay_ms: "250",
    };

    await runDeno("utils/couchdb/provision.ts", ["--allow-env", "--allow-net"], environment);
    const output = await runDeno("utils/setup/generate_setup_uri.ts", ["--allow-env"], environment);
    const setupURI = output.split(/\r?\n/u).find((line) => line.startsWith("obsidian://setuplivesync?settings="));
    if (!setupURI) throw new Error("The public Setup URI generator did not emit a Setup URI.");
    return { setupURI, setupPassphrase };
}

async function startUnconfiguredSession(
    context: RunnerContext,
    vault: TemporaryVault
): Promise<ObsidianLiveSyncSession> {
    const session = await startObsidianLiveSyncSession({
        binary: context.binary,
        cliBinary: context.cliBinary,
        vault,
        startupGraceMs: Number(process.env.E2E_OBSIDIAN_STARTUP_GRACE_MS ?? 1000),
    });
    context.activeSessions.add(session);
    return session;
}

async function stopTrackedSession(context: RunnerContext, session: ObsidianLiveSyncSession): Promise<void> {
    if (!context.activeSessions.has(session)) return;
    await session.app.stop();
    context.activeSessions.delete(session);
}

async function stopTrackedSessions(context: RunnerContext): Promise<void> {
    for (const session of [...context.activeSessions]) {
        await stopTrackedSession(context, session);
    }
}

async function enterSetupURI(port: number, mode: "new" | "existing", artifact: SetupArtifact): Promise<void> {
    await withObsidianPage(port, async (page) => {
        const invitation = page.locator(".notice").filter({ hasText: "Welcome to Self-hosted LiveSync" });
        await invitation.waitFor({ state: "visible", timeout: uiTimeoutMs });
        await invitation.locator(".sls-onboarding-invitation-action").click({ timeout: uiTimeoutMs });

        const intro = modalByTitle(page, "Welcome to Self-hosted LiveSync");
        await intro.waitFor({ state: "visible", timeout: uiTimeoutMs });
        if (mode === "new") {
            await selectRadioOption(intro, "I am setting this up for the first time");
            await intro
                .getByRole("button", { name: "Yes, I want to set up a new synchronisation" })
                .click({ timeout: uiTimeoutMs });
        } else {
            await selectRadioOption(intro, "I am adding a device to an existing synchronisation setup");
            await intro
                .getByRole("button", { name: "Yes, I want to add this device to my existing synchronisation" })
                .click({ timeout: uiTimeoutMs });
        }

        const method = modalByTitle(page, mode === "new" ? "Connection Method" : "Device Setup Method");
        await method.waitFor({ state: "visible", timeout: uiTimeoutMs });
        await selectRadioOption(method, "Use a Setup URI (Recommended)");
        await method.getByRole("button", { name: "Proceed with Setup URI" }).click({ timeout: uiTimeoutMs });

        const setup = modalByTitle(page, "Enter Setup URI");
        await setup.waitFor({ state: "visible", timeout: uiTimeoutMs });
        await setup.locator('input[placeholder^="obsidian://setuplivesync"]').fill(artifact.setupURI);
        await setup.locator('input[name="password"]').fill(artifact.setupPassphrase);
    });
    await captureGuideDialogue(
        port,
        `guide-quick-setup-${mode === "new" ? "first" : "second"}-setup-uri.png`,
        "Enter Setup URI"
    );
    await withObsidianPage(port, async (page) => {
        await modalByTitle(page, "Enter Setup URI")
            .getByRole("button", { name: "Test Settings and Continue" })
            .click({ timeout: uiTimeoutMs });
    });
}

async function captureAndStartInitialisation(port: number, mode: "new" | "existing"): Promise<string> {
    const title =
        mode === "new"
            ? "Setup Complete: Preparing to Initialise Server"
            : "Setup Complete: Preparing to Fetch Synchronisation Data";
    const button = mode === "new" ? "Restart and Initialise Server" : "Restart and Fetch Data";
    const screenshot = await captureObsidianDialogue(
        port,
        `setup-uri-${mode === "new" ? "first-initialise" : "second-fetch"}.png`,
        async (page) => {
            await modalByTitle(page, title).waitFor({ state: "visible", timeout: uiTimeoutMs });
        }
    );
    await captureGuideDialogue(
        port,
        `guide-quick-setup-${mode === "new" ? "first-initialise" : "second-fetch"}.png`,
        title
    );
    await withObsidianPage(port, async (page) => {
        await modalByTitle(page, title).getByRole("button", { name: button }).click({ timeout: uiTimeoutMs });
    });
    return screenshot;
}

async function confirmRebuild(port: number): Promise<string> {
    const title = "Final Confirmation: Overwrite Server Data with This Device's Files";
    const screenshot = await captureObsidianDialogue(port, "setup-uri-first-rebuild-confirmation.png", async (page) => {
        await modalByTitle(page, title).waitFor({ state: "visible", timeout: uiTimeoutMs });
    });
    await captureGuideDialogue(port, "guide-quick-setup-first-rebuild-confirmation.png", title);
    await withObsidianPage(port, async (page) => {
        const modal = modalByTitle(page, title);
        await selectCheckbox(
            modal,
            "I understand that all changes made on other smartphones or computers possibly could be lost."
        );
        await selectCheckbox(
            modal,
            "I understand that other devices will no longer be able to synchronise, and will need to be reset the synchronisation information."
        );
        await selectCheckbox(modal, "I understand that this action is irreversible once performed.");
        await selectRadioOption(modal, "I understand the risks and will proceed without a backup.");
        await modal.getByRole("button", { name: "I Understand, Overwrite Server" }).click({ timeout: uiTimeoutMs });
    });
    return screenshot;
}

async function continueWithoutRemoteSettings(port: number): Promise<string> {
    const title = "No Synchronisation Settings Found";
    const screenshot = await captureObsidianDialogue(
        port,
        "setup-uri-first-missing-remote-configuration.png",
        async (page) => {
            const modal = modalByTitle(page, title);
            await modal.waitFor({ state: "visible", timeout: uiTimeoutMs });
            await modal
                .getByText("This is normal for a new remote.", {
                    exact: false,
                })
                .waitFor({ state: "visible", timeout: uiTimeoutMs });
            await modal.getByRole("button", { name: "Cancel", exact: true }).waitFor({
                state: "visible",
                timeout: uiTimeoutMs,
            });
        }
    );
    await captureGuideDialogue(port, "guide-quick-setup-missing-remote-configuration.png", title);
    await withObsidianPage(port, async (page) => {
        await modalByTitle(page, title)
            .getByRole("button", { name: "Use this device's settings" })
            .click({ timeout: uiTimeoutMs });
    });
    return screenshot;
}

async function acknowledgeDisabledOptionalFeatures(port: number): Promise<string> {
    const title = "All optional features are disabled";
    const screenshot = await captureObsidianDialogue(
        port,
        "setup-uri-first-optional-features-disabled.png",
        async (page) => {
            const modal = modalByTitle(page, title);
            await modal.waitFor({ state: "visible", timeout: initialisationTimeoutMs });
            await modal
                .getByText("Please enable them from the settings screen after setup is complete.", {
                    exact: false,
                })
                .waitFor({ state: "visible", timeout: uiTimeoutMs });
        }
    );
    await captureGuideDialogue(port, "guide-quick-setup-optional-features-disabled.png", title);
    await withObsidianPage(port, async (page) => {
        await modalByTitle(page, title).getByRole("button", { name: "OK" }).click({ timeout: uiTimeoutMs });
    });
    return screenshot;
}

async function confirmFastFetch(port: number): Promise<string[]> {
    const firstTitle = "Data retrieval scheduled";
    await assertVerticalActionLayout(port, firstTitle);
    const firstScreenshot = await captureObsidianDialogue(
        port,
        "setup-uri-second-retrieval-method.png",
        async (page) => {
            await modalByTitle(page, firstTitle).waitFor({ state: "visible", timeout: uiTimeoutMs });
        }
    );
    await captureGuideDialogue(port, "guide-quick-setup-retrieval-method.png", firstTitle);
    await withObsidianPage(port, async (page) => {
        await modalByTitle(page, firstTitle)
            .getByRole("button", { name: "Overwrite all with remote files" })
            .click({ timeout: uiTimeoutMs });
    });

    const secondTitle = "How to handle extra existing local files?";
    await assertVerticalActionLayout(port, secondTitle);
    const secondScreenshot = await captureObsidianDialogue(
        port,
        "setup-uri-second-local-file-policy.png",
        async (page) => {
            await modalByTitle(page, secondTitle).waitFor({ state: "visible", timeout: uiTimeoutMs });
        }
    );
    await captureGuideDialogue(port, "guide-quick-setup-local-file-policy.png", secondTitle);
    await withObsidianPage(port, async (page) => {
        await modalByTitle(page, secondTitle)
            .getByRole("button", { name: "Keep local files even if not on remote" })
            .click({ timeout: uiTimeoutMs });
    });
    return [firstScreenshot, secondScreenshot];
}

function isConfiguredSetupReady(state: SetupState): boolean {
    return (
        state.configured &&
        state.databaseReady &&
        state.appReady &&
        !state.suspended &&
        state.activeConfigurationId !== "" &&
        state.remoteConfigurationCount === 1
    );
}

async function finishInitialisation(
    port: number,
    filename: string,
    cliBinary: string,
    environment: NodeJS.ProcessEnv
): Promise<{ state: SetupState; screenshot?: string }> {
    const message = "Do you want to resume file and database processing, and restart obsidian now?";
    const deadline = Date.now() + initialisationTimeoutMs;
    let lastState: SetupState | undefined;
    let lastError: unknown;
    while (Date.now() < deadline) {
        const resumeVisible = await withObsidianPage(port, async (page) => {
            return await modalByTitle(page, "Confirmation").filter({ hasText: message }).isVisible();
        }).catch(() => false);
        if (resumeVisible) {
            const screenshot = await captureObsidianDialogue(port, filename, async (page) => {
                await modalByTitle(page, "Confirmation")
                    .filter({ hasText: message })
                    .waitFor({ state: "visible", timeout: uiTimeoutMs });
            });
            await withObsidianPage(port, async (page) => {
                const modal = modalByTitle(page, "Confirmation").filter({ hasText: message });
                await modal.getByText(message, { exact: true }).click({ timeout: uiTimeoutMs });
                await modal.getByRole("button", { name: "Yes", exact: true }).click({ timeout: uiTimeoutMs });
            });
            return {
                state: await waitForConfiguredSetup(cliBinary, environment, initialisationTimeoutMs),
                screenshot,
            };
        }
        try {
            lastState = await readSetupState(cliBinary, environment);
            if (isConfiguredSetupReady(lastState)) return { state: lastState };
        } catch (error) {
            lastError = error;
        }
        await new Promise((resolve) => setTimeout(resolve, 500));
    }
    throw new Error(
        `Timed out waiting for Setup URI initialisation to finish: ${JSON.stringify(lastState)}${
            lastError instanceof Error ? `; last error: ${lastError.message}` : ""
        }`
    );
}

async function readSetupState(cliBinary: string, environment: NodeJS.ProcessEnv): Promise<SetupState> {
    return await evalObsidianJson<SetupState>(
        cliBinary,
        [
            "(()=>{",
            "const core=app.plugins.plugins['obsidian-livesync'].core;",
            "const settings=core.services.setting.currentSettings();",
            "return JSON.stringify({",
            "configured:settings.isConfigured===true,",
            "databaseReady:core.services.database.isDatabaseReady(),",
            "appReady:core.services.appLifecycle.isReady(),",
            "suspended:core.services.appLifecycle.isSuspended(),",
            "remoteType:settings.remoteType,",
            "activeConfigurationId:settings.activeConfigurationId||'',",
            "remoteConfigurationCount:Object.keys(settings.remoteConfigurations||{}).length,",
            "syncInternalFiles:settings.syncInternalFiles===true,",
            "syncInternalFilesBeforeReplication:settings.syncInternalFilesBeforeReplication===true,",
            "});",
            "})()",
        ].join(""),
        environment
    );
}

async function waitForConfiguredSetup(
    cliBinary: string,
    environment: NodeJS.ProcessEnv,
    timeoutMs = initialisationTimeoutMs
): Promise<SetupState> {
    const deadline = Date.now() + timeoutMs;
    let lastState: SetupState | undefined;
    let lastError: unknown;
    while (Date.now() < deadline) {
        try {
            lastState = await readSetupState(cliBinary, environment);
            if (isConfiguredSetupReady(lastState)) {
                return lastState;
            }
        } catch (error) {
            lastError = error;
        }
        await new Promise((resolve) => setTimeout(resolve, 500));
    }
    throw new Error(
        `Timed out waiting for configured Setup URI state: ${JSON.stringify(lastState)}${
            lastError instanceof Error ? `; last error: ${lastError.message}` : ""
        }`
    );
}

async function enableHiddenFileSync(cliBinary: string, environment: NodeJS.ProcessEnv): Promise<SetupState> {
    await evalObsidianJson<unknown>(
        cliBinary,
        [
            "(async()=>{",
            "const core=app.plugins.plugins['obsidian-livesync'].core;",
            "await core.services.setting.applyPartial({",
            "syncInternalFiles:true,",
            "syncInternalFilesBeforeReplication:true,",
            "},true);",
            "await core.services.control.applySettings();",
            "return JSON.stringify({ok:true});",
            "})()",
        ].join(""),
        environment
    );
    const state = await waitForConfiguredSetup(cliBinary, environment);
    if (!state.syncInternalFiles || !state.syncInternalFilesBeforeReplication) {
        throw new Error(`Hidden File Sync was not enabled after setup: ${JSON.stringify(state)}`);
    }
    return state;
}

async function captureHiddenFileGuideSettings(
    port: number,
    cliBinary: string,
    environment: NodeJS.ProcessEnv
): Promise<string[]> {
    await evalObsidianJson<unknown>(
        cliBinary,
        [
            "(async()=>{",
            "const core=app.plugins.plugins['obsidian-livesync'].core;",
            "await core.services.setting.applyPartial({",
            "useAdvancedMode:true,",
            "syncInternalFilesTargetPatterns:'^\\\\.obsidian(?:$|/snippets(?:/|$))',",
            "},true);",
            "await core.services.control.applySettings();",
            "return JSON.stringify({ok:true});",
            "})()",
        ].join(""),
        environment
    );

    const screenshots = [
        await captureObsidianElement(port, "guide-hidden-file-advanced-features.png", (page) =>
            liveSyncSettingPanelByTitle(page, "General Settings", "Extra menus")
        ),
    ];

    screenshots.push(
        await captureObsidianElement(port, "guide-hidden-file-selector.png", (page) =>
            liveSyncSettingPanelByTitle(page, "Selector", "Hidden Files")
        )
    );

    screenshots.push(
        await captureObsidianElement(port, "guide-hidden-file-enable.png", (page) =>
            liveSyncSettingPanelByTitle(page, "Sync Settings", "Hidden Files")
        )
    );

    await withObsidianPage(port, async (page) => {
        const settingsNavigator = await openLiveSyncSettings(page, uiTimeoutMs);
        await settingsNavigator.close();
    });
    return screenshots;
}

async function writeNoteViaObsidian(
    cliBinary: string,
    environment: NodeJS.ProcessEnv,
    path: string,
    content: string
): Promise<void> {
    await evalObsidianJson<unknown>(
        cliBinary,
        [
            "(async()=>{",
            `const path=${JSON.stringify(path)};`,
            `const content=${JSON.stringify(content)};`,
            "const folder=path.split('/').slice(0,-1).join('/');",
            "if(folder&&!(await app.vault.adapter.exists(folder))) await app.vault.createFolder(folder);",
            "const existing=app.vault.getAbstractFileByPath(path);",
            "if(existing) await app.vault.modify(existing,content);",
            "else await app.vault.create(path,content);",
            "return JSON.stringify({ok:true});",
            "})()",
        ].join(""),
        environment
    );
}

async function scanHiddenStorage(cliBinary: string, environment: NodeJS.ProcessEnv): Promise<void> {
    await evalObsidianJson<unknown>(
        cliBinary,
        [
            "(async()=>{",
            "const core=app.plugins.plugins['obsidian-livesync'].core;",
            "const addOn=core.getAddOn('HiddenFileSync');",
            "await addOn.scanAllStorageChanges(true);",
            "return JSON.stringify({ok:true});",
            "})()",
        ].join(""),
        environment,
        hiddenFileCliTimeoutMs
    );
}

async function scanHiddenDatabase(cliBinary: string, environment: NodeJS.ProcessEnv): Promise<void> {
    await evalObsidianJson<unknown>(
        cliBinary,
        [
            "(async()=>{",
            "const core=app.plugins.plugins['obsidian-livesync'].core;",
            "const addOn=core.getAddOn('HiddenFileSync');",
            "await addOn.scanAllDatabaseChanges(true);",
            "return JSON.stringify({ok:true});",
            "})()",
        ].join(""),
        environment,
        hiddenFileCliTimeoutMs
    );
}

async function waitForRemoteEntry(context: RunnerContext, entry: LocalDatabaseEntry): Promise<void> {
    await waitForCouchDbDocs(context.couchDb, context.dbName, (docs) => {
        const ids = new Set(docs.map((doc) => doc._id));
        return ids.has(entry.id) && entry.children.every((childId) => ids.has(childId));
    });
}

async function uploadWorkflowFiles(
    context: RunnerContext,
    session: ObsidianLiveSyncSession,
    vault: TemporaryVault
): Promise<void> {
    await writeNoteViaObsidian(context.cliBinary, session.cliEnv, notePath, noteContent);
    await writeVaultFile(vault.path, snippetPath, snippetContent);
    await scanHiddenStorage(context.cliBinary, session.cliEnv);
    const noteEntry = await waitForLocalDatabaseEntry(context.cliBinary, session.cliEnv, notePath);
    const snippetEntry = await waitForLocalDatabaseEntry(context.cliBinary, session.cliEnv, snippetPath, {
        hidden: true,
    });
    await pushLocalChanges(context.cliBinary, session.cliEnv);
    await waitForRemoteEntry(context, noteEntry);
    await waitForRemoteEntry(context, snippetEntry);
}

async function captureSynchronisedNote(port: number): Promise<string> {
    await withObsidianPage(port, async (page) => {
        await page.evaluate((path) => {
            const obsidian = globalThis as typeof globalThis & {
                app?: {
                    workspace?: { openLinkText(path: string, sourcePath: string, newLeaf: boolean): Promise<void> };
                };
            };
            return obsidian.app?.workspace?.openLinkText(path, "", false);
        }, notePath);
    });
    await captureObsidianPage(port, "setup-uri-synchronised-note.png", async (page) => {
        await page.getByText("Provisioned Setup URI", { exact: false }).first().waitFor({
            state: "visible",
            timeout: uiTimeoutMs,
        });
    });
    return await captureObsidianElement(port, "guide-quick-setup-synchronised-note.png", (page) =>
        page.locator(".workspace-leaf.mod-active").first()
    );
}

async function captureFailure(session: ObsidianLiveSyncSession): Promise<void> {
    await captureObsidianPage(
        session.remoteDebuggingPort,
        "setup-uri-workflow.failure.png",
        async () => undefined
    ).catch(() => undefined);
}

async function main(): Promise<void> {
    const binary = requireObsidianBinary();
    const cli = discoverObsidianCli();
    if (!cli.binary) throw new Error(`Could not find obsidian-cli. Checked paths: ${cli.checked.join(", ")}`);

    const couchDb = await loadCouchDbConfig();
    const dbName = makeUniqueDatabaseName(couchDb.dbPrefix, "setup-uri-workflow");
    const vaultA = await createTemporaryVault();
    const vaultB = await createTemporaryVault();
    const context: RunnerContext = {
        binary,
        cliBinary: cli.binary,
        couchDb,
        dbName,
        activeSessions: new Set(),
    };
    const screenshots: string[] = [];
    let secondDeviceArtifact: SetupArtifact | undefined;

    try {
        await assertCouchDbReachable(couchDb);
        const artifact = await provisionAndGenerateSetupURI(couchDb, dbName);
        const provisionedDocs = await waitForCouchDbDocs(couchDb, dbName, (docs) =>
            docs.some((doc) => doc._id === "obsydian_livesync_version" && doc.version === VER)
        );
        if (!provisionedDocs.some((doc) => doc._id === "obsydian_livesync_version" && doc.version === VER)) {
            throw new Error("The public provisioning tool did not initialise the Commonlib database version.");
        }

        console.log(`Using Obsidian executable: ${binary}`);
        console.log(`Temporary vault A: ${vaultA.path}`);
        console.log(`Temporary vault B: ${vaultB.path}`);
        console.log(`Temporary provisioned CouchDB database: ${dbName}`);

        let session = await startUnconfiguredSession(context, vaultA);
        try {
            await enterSetupURI(session.remoteDebuggingPort, "new", artifact);
            screenshots.push(await captureAndStartInitialisation(session.remoteDebuggingPort, "new"));
            screenshots.push(await confirmRebuild(session.remoteDebuggingPort));
            screenshots.push(await continueWithoutRemoteSettings(session.remoteDebuggingPort));
            screenshots.push(await acknowledgeDisabledOptionalFeatures(session.remoteDebuggingPort));
            const firstCompletion = await finishInitialisation(
                session.remoteDebuggingPort,
                "setup-uri-first-initialisation-complete.png",
                context.cliBinary,
                session.cliEnv
            );
            if (firstCompletion.screenshot) screenshots.push(firstCompletion.screenshot);
            const firstState = firstCompletion.state;
            await resumeCompatibilityReviewIfShown(session.remoteDebuggingPort);
            assertEqual(firstState.remoteType, "", "The first device did not activate the CouchDB remote profile.");
            assertEqual(
                firstState.syncInternalFiles,
                false,
                "Rebuild did not retain the documented optional-feature safety boundary."
            );
            screenshots.push(
                ...(await captureHiddenFileGuideSettings(
                    session.remoteDebuggingPort,
                    context.cliBinary,
                    session.cliEnv
                ))
            );
            await enableHiddenFileSync(context.cliBinary, session.cliEnv);
            await uploadWorkflowFiles(context, session, vaultA);
            const generated = await generateSetupURIFromDevice(
                session.remoteDebuggingPort,
                randomBytes(24).toString("base64url"),
                { scenario: "setup-uri-workflow", guide: "quick-setup" }
            );
            if (generated.artifact.setupURI === artifact.setupURI) {
                throw new Error("The first device returned the bootstrap Setup URI instead of generating a new one.");
            }
            secondDeviceArtifact = generated.artifact;
            screenshots.push(...generated.screenshots);
        } catch (error) {
            await captureFailure(session);
            throw error;
        } finally {
            await stopTrackedSession(context, session);
        }

        session = await startUnconfiguredSession(context, vaultB);
        try {
            if (!secondDeviceArtifact)
                throw new Error("The first device did not generate the second-device Setup URI.");
            await enterSetupURI(session.remoteDebuggingPort, "existing", secondDeviceArtifact);
            screenshots.push(await captureAndStartInitialisation(session.remoteDebuggingPort, "existing"));
            screenshots.push(...(await confirmFastFetch(session.remoteDebuggingPort)));
            const secondCompletion = await finishInitialisation(
                session.remoteDebuggingPort,
                "setup-uri-second-initialisation-complete.png",
                context.cliBinary,
                session.cliEnv
            );
            if (secondCompletion.screenshot) screenshots.push(secondCompletion.screenshot);
            const secondState = secondCompletion.state;
            await resumeCompatibilityReviewIfShown(session.remoteDebuggingPort);
            assertEqual(secondState.remoteType, "", "The second device did not activate the CouchDB remote profile.");
            await enableHiddenFileSync(context.cliBinary, session.cliEnv);
            await pushLocalChanges(context.cliBinary, session.cliEnv);
            await scanHiddenDatabase(context.cliBinary, session.cliEnv);
            const receivedNote = await waitForPathContent(vaultB.path, notePath, noteContent);
            const receivedSnippet = await waitForPathContent(vaultB.path, snippetPath, snippetContent);
            assertEqual(receivedNote, noteContent, "The ordinary note did not reach the second Setup URI device.");
            assertEqual(
                receivedSnippet,
                snippetContent,
                "The hidden snippet did not reach the second Setup URI device."
            );
            screenshots.push(await captureSynchronisedNote(session.remoteDebuggingPort));
            await writeNoteViaObsidian(context.cliBinary, session.cliEnv, returnNotePath, returnNoteContent);
            const returnEntry = await waitForLocalDatabaseEntry(context.cliBinary, session.cliEnv, returnNotePath);
            await pushLocalChanges(context.cliBinary, session.cliEnv);
            await waitForRemoteEntry(context, returnEntry);
        } catch (error) {
            await captureFailure(session);
            throw error;
        } finally {
            await stopTrackedSession(context, session);
        }

        session = await startUnconfiguredSession(context, vaultA);
        try {
            await resumeCompatibilityReviewIfShown(session.remoteDebuggingPort);
            await pushLocalChanges(context.cliBinary, session.cliEnv);
            const receivedReturnNote = await waitForPathContent(vaultA.path, returnNotePath, returnNoteContent);
            assertEqual(
                receivedReturnNote,
                returnNoteContent,
                "The second device's ordinary note did not return to the first Setup URI device."
            );
        } catch (error) {
            await captureFailure(session);
            throw error;
        } finally {
            await stopTrackedSession(context, session);
        }

        console.log(
            `The public provisioning and first-device-generated Setup URI workflow configured two fresh devices, completed an ordinary-note round-trip, and synchronised a hidden snippet. Screenshots: ${screenshots.join(", ")}`
        );
    } finally {
        await stopTrackedSessions(context).catch((error: unknown) => {
            console.warn(error instanceof Error ? error.message : error);
        });
        await vaultA.dispose();
        await vaultB.dispose();
        if (process.env.E2E_OBSIDIAN_KEEP_COUCHDB !== "true") {
            await deleteCouchDbDatabase(couchDb, dbName).catch((error: unknown) => {
                console.warn(error instanceof Error ? error.message : error);
            });
        }
    }
}

main().catch((error: unknown) => {
    console.error(error instanceof Error ? error.stack : error);
    process.exit(1);
});
