import {
    _electron as electron,
    APIRequestContext,
    ElectronApplication,
    expect,
    Locator,
    Page,
    test as base,
} from '@playwright/test';
import { createServer, Server } from 'http';
import {
    existsSync,
    mkdtempSync,
    readFileSync,
    rmSync,
    writeFileSync,
} from 'fs';
import { tmpdir } from 'os';
import { join, resolve } from 'path';

export const workspaceRoot = resolve(__dirname, '../../..');
export const electronMainPath = join(
    workspaceRoot,
    'dist/apps/electron-backend/main.js'
);
export const packagedRendererIndexPath = join(
    workspaceRoot,
    'dist/apps/web/index.html'
);
export const m3uFixturePath = join(
    workspaceRoot,
    'apps/web-e2e/src/fixtures/test.m3u'
);
export const stalkerMockPort = process.env['MOCK_PORT'] ?? '3210';
export const xtreamMockPort = process.env['XTREAM_MOCK_PORT'] ?? '3211';
export const stalkerMockServer = `http://localhost:${stalkerMockPort}`;
export const xtreamMockServer = `http://localhost:${xtreamMockPort}`;
export const defaultXtreamPortalName = 'Mock Xtream Portal';
export const defaultStalkerPortalName = 'Mock Stalker Portal';
export const defaultXtreamUsername = 'user1';
export const defaultXtreamPassword = 'pass1';
export const defaultStalkerMacAddress = '00:1A:79:00:00:01';

export type PortalProvider = 'stalker' | 'xtream';

export type M3uTestChannel = {
    groupTitle?: string;
    logo?: string;
    name: string;
    tvgId?: string;
    tvgName?: string;
    url: string;
};

export type MutableTextServer = {
    close: () => Promise<void>;
    origin: string;
    resourceUrl: string;
    setBody: (body: string) => void;
};

type ElectronFixtures = {
    dataDir: string;
};

type LaunchElectronAppOptions = {
    env?: Record<string, string | undefined>;
};

export type PortalDebugEvent = {
    durationMs: number;
    operation: string;
    provider: PortalProvider;
    request: unknown;
    requestId: string;
    response?: unknown;
    startedAt: string;
    status: 'success' | 'error';
    transport: 'electron-main' | 'electron-renderer' | 'pwa-http';
};

export type DbOperationEvent = {
    operationId?: string;
    operation: string;
    playlistId?: string;
    status: 'started' | 'progress' | 'completed' | 'cancelled' | 'error';
    phase?: string;
    current?: number;
    total?: number;
    increment?: number;
    error?: string;
};

declare global {
    interface Window {
        __dbOperationEvents?: DbOperationEvent[];
        __dbOperationUnsubscribe?: (() => void) | undefined;
        __portalDebugEvents?: PortalDebugEvent[];
        __portalDebugUnsubscribe?: (() => void) | undefined;
        __rendererFrameCount?: number;
        __rendererFrameRequestId?: number;
    }
}

export type LaunchedElectronApp = {
    electronApp: ElectronApplication;
    mainWindow: Page;
};

export const test = base.extend<ElectronFixtures>({
    dataDir: async ({ browserName }, use) => {
        void browserName;
        const dataDir = mkdtempSync(join(tmpdir(), 'iptvnator-electron-e2e-'));

        await use(dataDir);

        rmSync(dataDir, { force: true, recursive: true });
    },
});

export { expect };

export async function launchElectronApp(
    dataDir: string,
    options: LaunchElectronAppOptions = {}
): Promise<LaunchedElectronApp> {
    if (!existsSync(electronMainPath)) {
        throw new Error(
            `Electron build not found at ${electronMainPath}. Run the build before executing the E2E suite.`
        );
    }
    assertPackagedRendererBuildIsElectronSafe();

    const args = [electronMainPath];

    if (process.platform === 'linux' && process.env['CI']) {
        args.unshift('--no-sandbox', '--disable-gpu');
    }

    const electronApp = await electron.launch({
        args,
        env: {
            ...process.env,
            ...options.env,
            ELECTRON_IS_DEV: '0',
            IPTVNATOR_E2E_DATA_DIR: dataDir,
            NODE_ENV: 'test',
        },
    });

    const mainWindow = await findMainWindow(electronApp);
    await waitForAppReady(mainWindow);
    await startPortalDebugCapture(mainWindow);
    await startDbOperationCapture(mainWindow);
    await startRendererFrameCapture(mainWindow);

    return {
        electronApp,
        mainWindow,
    };
}

export async function closeElectronApp(
    app: LaunchedElectronApp
): Promise<void> {
    try {
        await app.electronApp.close();
    } catch (error) {
        console.warn('Failed to close Electron app cleanly:', error);
    }
}

function assertPackagedRendererBuildIsElectronSafe(): void {
    if (!existsSync(packagedRendererIndexPath)) {
        throw new Error(
            `Renderer build not found at ${packagedRendererIndexPath}. Run pnpm nx run electron-backend:build-e2e before executing the Electron E2E suite.`
        );
    }

    const indexHtml = readFileSync(packagedRendererIndexPath, 'utf8');
    const baseHrefMatch = indexHtml.match(/<base\s+href="([^"]*)"/i);
    const baseHref = baseHrefMatch?.[1] ?? '<missing>';

    if (baseHref !== './') {
        throw new Error(
            `Renderer build at ${packagedRendererIndexPath} is not file-safe for packaged Electron. Found base href ${JSON.stringify(baseHref)}. Run pnpm nx run electron-backend:build-e2e or use an Electron E2E Nx target so dist/apps/web is rebuilt with the electron-e2e configuration.`
        );
    }
}

async function findMainWindow(app: ElectronApplication): Promise<Page> {
    await new Promise((resolvePromise) => setTimeout(resolvePromise, 2000));

    const windows = app.windows();

    for (const window of windows) {
        const title = await window.title();

        if (!title.includes('DevTools')) {
            return window;
        }
    }

    return app.firstWindow();
}

async function waitForAppReady(page: Page): Promise<void> {
    await page.waitForLoadState('domcontentloaded');
    await page.waitForSelector('app-root', { timeout: 30000 });
    try {
        await page.waitForFunction(
            () => {
                const appRoot = document.querySelector('app-root');

                return Boolean(appRoot && appRoot.innerHTML.trim().length > 0);
            },
            { timeout: 30000 }
        );
    } catch (error) {
        const diagnostics = await page.evaluate(() => ({
            appRootLength:
                document.querySelector('app-root')?.innerHTML.trim().length ?? 0,
            baseHref:
                document
                    .querySelector('base')
                    ?.getAttribute('href') ?? '<missing>',
            readyState: document.readyState,
            title: document.title,
            url: location.href,
        }));

        const reason =
            error instanceof Error ? error.message : 'unknown startup error';

        throw new Error(
            `Electron app did not render visible app-root within 30000ms. ${reason}. Diagnostics: ${JSON.stringify(
                diagnostics
            )}`
        );
    }
}

export async function openAddPlaylistDialog(page: Page): Promise<void> {
    await page.getByRole('button', { name: 'Add playlist' }).click();
    await expect(page.locator('mat-dialog-container').last()).toBeVisible();
}

async function getActiveDialog(page: Page): Promise<Locator> {
    const dialog = page.locator('mat-dialog-container').last();
    await expect(dialog).toBeVisible();
    return dialog;
}

export async function stubNativePlaylistFileDialog(
    electronApp: ElectronApplication,
    filePath: string
): Promise<void> {
    await electronApp.evaluate(async ({ dialog }, selectedFilePath) => {
        dialog.showOpenDialog = async () =>
            ({
                canceled: false,
                filePaths: [selectedFilePath],
            }) as any;
    }, filePath);
}

export async function importM3uPlaylistFromNativeDialog(
    app: LaunchedElectronApp,
    filePath: string
): Promise<void> {
    await openAddPlaylistDialog(app.mainWindow);
    const dialog = await getActiveDialog(app.mainWindow);
    await clickDialogCategoryOption(dialog, /^m3u$/i);
    await clickDialogSubtypeOption(
        dialog,
        /m3u\s*file|add\s+via\s+file\s+upload/i,
        'mat-button-toggle[value="file"]'
    );
    const fileInput = dialog.locator('input[type="file"][name="playlist"]');

    await fileInput.evaluate((element, selectedFilePath) => {
        (element as HTMLInputElement).dataset.filePathOverride =
            selectedFilePath;
    }, filePath);
    await fileInput.setInputFiles(filePath);
    await expect(
        dialog.getByRole('button', { name: /add playlist/i })
    ).toBeEnabled({ timeout: 10000 });
    await dialog.getByRole('button', { name: /add playlist/i }).click();
    await dialog.waitFor({ state: 'detached' });
}

export async function dropM3uPlaylistOntoWorkspace(
    page: Page,
    filePath: string
): Promise<void> {
    await expect(page.locator('.workspace-shell')).toBeVisible({
        timeout: 20000,
    });

    const inputHandle = await page.evaluateHandle(() => {
        const input = document.createElement('input');
        input.type = 'file';
        input.style.position = 'fixed';
        input.style.left = '-9999px';
        document.body.append(input);
        return input;
    });
    const inputElement = inputHandle.asElement();
    if (!inputElement) {
        await inputHandle.dispose();
        throw new Error('Could not create temporary file input.');
    }

    await inputElement.setInputFiles(filePath);

    await page.evaluate((element) => {
        const input = element as HTMLInputElement;
        const file = input.files?.[0];
        if (!file) {
            throw new Error('Temporary file input did not receive a file.');
        }

        const target = document.querySelector('.workspace-shell');
        if (!target) {
            throw new Error('Workspace drop target was not found.');
        }

        const dataTransfer = new DataTransfer();
        dataTransfer.items.add(file);

        for (const type of ['dragenter', 'dragover', 'drop']) {
            target.dispatchEvent(
                new DragEvent(type, {
                    bubbles: true,
                    cancelable: true,
                    dataTransfer,
                })
            );
        }

        input.remove();
    }, inputElement);
    await inputHandle.dispose();
}

export async function addXtreamPortal(
    page: Page,
    options: {
        name?: string;
        password?: string;
        serverUrl?: string;
        username?: string;
    } = {}
): Promise<void> {
    const {
        name = defaultXtreamPortalName,
        password = defaultXtreamPassword,
        serverUrl = xtreamMockServer,
        username = defaultXtreamUsername,
    } = options;

    await openAddPlaylistDialog(page);
    const dialog = await getActiveDialog(page);
    await clickDialogCategoryOption(
        dialog,
        /xtream(\s+credentials)?/i,
        'mat-button-toggle[value="xtream"]'
    );

    await setInputValue(dialog.locator('#title'), name);
    await setInputValue(dialog.locator('#serverUrl'), serverUrl);
    await setInputValue(dialog.locator('#username'), username);
    await setInputValue(dialog.locator('#password'), password);
    const addButton = dialog
        .getByRole('button', { name: /^(add|add playlist)$/i })
        .last();

    await expect(addButton).toBeEnabled({ timeout: 10000 });
    await addButton.click();
    await dialog.waitFor({ state: 'detached' });
}

async function setInputValue(input: Locator, value: string): Promise<void> {
    await input.fill('');
    await input.fill(value);

    if ((await input.inputValue()) === value) {
        return;
    }

    await input.click();
    await input.press('Control+A');
    await input.press('Backspace');
    await input.type(value);

    if ((await input.inputValue()) === value) {
        return;
    }

    await input.evaluate((element, nextValue) => {
        const inputElement = element as HTMLInputElement;
        inputElement.value = nextValue;
        inputElement.dispatchEvent(new Event('input', { bubbles: true }));
        inputElement.dispatchEvent(new Event('change', { bubbles: true }));
        inputElement.dispatchEvent(new Event('blur', { bubbles: true }));
    }, value);

    await expect(input).toHaveValue(value);
}

/**
 * Pick a source method on the Add Playlist dialog. Since v0.22 the dialog
 * exposes a single flat 5-card radiogroup ("M3U URL", "M3U file", "Xtream
 * credentials", "Stalker portal", "Raw m3u text") instead of the legacy
 * 2-level category × subtype tabs/toggles. The helper still falls through
 * to the old tab/button/legacy-selector lookups so we don't have to rewrite
 * every call-site on each redesign — but the radio-based picker is the
 * primary path now.
 */
async function clickDialogMethodOption(
    dialog: Locator,
    label: RegExp,
    legacySelector?: string
): Promise<void> {
    const optionByRadio = dialog
        .getByRole('radio', { name: label })
        .first();
    if ((await optionByRadio.count()) > 0) {
        await optionByRadio.click();
        return;
    }

    for (const tablistLabel of ['Source method', 'Playlist category', 'M3U source']) {
        const tablist = dialog
            .locator(`[role="tablist"][aria-label="${tablistLabel}"]`)
            .first();
        if ((await tablist.count()) > 0) {
            const optionByTabRole = tablist
                .getByRole('tab', { name: label })
                .first();
            if ((await optionByTabRole.count()) > 0) {
                await optionByTabRole.click();
                return;
            }
        }
    }

    const optionByGlobalTabRole = dialog
        .getByRole('tab', { name: label })
        .first();
    if ((await optionByGlobalTabRole.count()) > 0) {
        await optionByGlobalTabRole.click();
        return;
    }

    const optionByButtonRole = dialog
        .getByRole('button', { name: label })
        .first();
    if ((await optionByButtonRole.count()) > 0) {
        await optionByButtonRole.click();
        return;
    }

    if (!legacySelector) {
        throw new Error(
            `Could not find dialog option matching ${label}.`
        );
    }

    await dialog.locator(legacySelector).click();
}

// Backwards-compat shims for the legacy two-step flow. Both helpers now route
// through `clickDialogMethodOption` and use the patterns of the new flat
// picker. `clickDialogCategoryOption` is a no-op for "M3U" since the new
// picker has no parent "M3U" tile — callers immediately follow up with a
// `clickDialogSubtypeOption` which picks the concrete M3U URL/file/text card.
async function clickDialogCategoryOption(
    dialog: Locator,
    label: RegExp,
    legacySelector?: string
): Promise<void> {
    // The legacy "M3U" category is now implicit — the new picker has no
    // standalone "M3U" radio; callers always immediately specialise via
    // `clickDialogSubtypeOption` below. Skip the click to avoid matching
    // unrelated radios (e.g. "M3U URL" when caller wanted "M3U file").
    if (/^\^?m3u\$?$/i.test(label.source)) {
        return;
    }
    await clickDialogMethodOption(dialog, label, legacySelector);
}

async function clickDialogSubtypeOption(
    dialog: Locator,
    label: RegExp,
    legacySelector?: string
): Promise<void> {
    await clickDialogMethodOption(dialog, label, legacySelector);
}

export async function addStalkerPortal(
    page: Page,
    options: {
        macAddress?: string;
        name?: string;
        portalUrl?: string;
    } = {}
): Promise<void> {
    const {
        macAddress = defaultStalkerMacAddress,
        name = defaultStalkerPortalName,
        portalUrl = `${stalkerMockServer}/portal.php`,
    } = options;

    await openAddPlaylistDialog(page);
    const dialog = await getActiveDialog(page);
    await clickDialogCategoryOption(
        dialog,
        /stalker(\s+portal)?/i,
        'mat-button-toggle[value="stalker"]'
    );

    await setInputValue(dialog.locator('input#title'), name);
    await setInputValue(dialog.locator('input#portalUrl'), portalUrl);
    await setInputValue(dialog.locator('input#macAddress'), macAddress);
    const addButton = dialog
        .getByRole('button', { name: /^(add|add playlist)$/i })
        .last();

    await expect(addButton).toBeEnabled({ timeout: 10000 });
    await addButton.click();
    await dialog.waitFor({ state: 'detached' });
}

export async function openSettings(page: Page): Promise<void> {
    await page.locator('a[href$="/workspace/settings"]').click();
    await expect(page.getByTestId('settings-container')).toBeVisible();
}

export async function enableRemoteControl(
    page: Page,
    port: number
): Promise<void> {
    const remoteControlCheckbox = page.locator(
        'mat-checkbox[formcontrolname="remoteControl"] input[type="checkbox"]'
    );

    await remoteControlCheckbox.scrollIntoViewIfNeeded();
    await remoteControlCheckbox.check();
    await page.locator('#remoteControlPort').fill(String(port));
}

export async function saveSettings(page: Page): Promise<void> {
    const saveButton = page.getByTestId('save-settings');

    await saveButton.click();
    await expect(saveButton).toBeDisabled();
    await page.waitForTimeout(300);
}

export async function goToDashboard(page: Page): Promise<void> {
    const dashboardLink = page
        .locator('a.brand[href$="/workspace/dashboard"]')
        .first();

    await expect(dashboardLink).toBeVisible();
    await dashboardLink.click();
    await page.waitForURL(/\/workspace\/dashboard$/);
}

export async function openSources(page: Page): Promise<void> {
    await page.getByRole('link', { name: 'Sources', exact: true }).click();
    await page.waitForURL(/\/workspace\/sources(?:\?.*)?$/);
}

export async function restartElectronApp(
    app: LaunchedElectronApp,
    dataDir: string,
    options: LaunchElectronAppOptions = {}
): Promise<LaunchedElectronApp> {
    await closeElectronApp(app);
    return launchElectronApp(dataDir, options);
}

export async function importM3uPlaylistFromUrl(
    page: Page,
    playlistUrl: string
): Promise<void> {
    await openAddPlaylistDialog(page);
    const dialog = await getActiveDialog(page);
    await clickDialogCategoryOption(dialog, /^m3u$/i);
    await clickDialogSubtypeOption(
        dialog,
        /m3u\s*url|add\s+via\s+url/i,
        'mat-button-toggle[value="url"]'
    );

    await setInputValue(
        dialog.locator('input[formcontrolname="playlistUrl"]'),
        playlistUrl
    );
    await dialog.getByRole('button', { name: /Add playlist/i }).click();
    await dialog.waitFor({ state: 'detached' });
}

export function buildM3uContent(channels: M3uTestChannel[]): string {
    const lines = ['#EXTM3U'];

    for (const channel of channels) {
        const attributes = [
            channel.tvgId ? `tvg-id="${channel.tvgId}"` : '',
            channel.tvgName ? `tvg-name="${channel.tvgName}"` : '',
            channel.logo ? `tvg-logo="${channel.logo}"` : '',
            channel.groupTitle ? `group-title="${channel.groupTitle}"` : '',
        ]
            .filter(Boolean)
            .join(' ');

        lines.push(
            `#EXTINF:-1${attributes ? ` ${attributes}` : ''},${channel.name}`
        );
        lines.push(channel.url);
    }

    return `${lines.join('\n')}\n`;
}

export function writeTemporaryM3uFile(
    dataDir: string,
    fileName: string,
    channels: M3uTestChannel[]
): string {
    const filePath = join(dataDir, fileName);
    writeFileSync(filePath, buildM3uContent(channels), 'utf8');
    return filePath;
}

export function parseM3uFixture(filePath: string): M3uTestChannel[] {
    const content = readFileSync(filePath, 'utf8');
    const items: M3uTestChannel[] = [];
    const lines = content.split(/\r?\n/);
    let pending:
        | {
              groupTitle?: string;
              logo?: string;
              name: string;
              tvgId?: string;
              tvgName?: string;
          }
        | undefined;

    for (const rawLine of lines) {
        const line = rawLine.trim();
        if (!line) {
            continue;
        }

        if (line.startsWith('#EXTINF:')) {
            pending = {
                groupTitle:
                    line.match(/group-title="([^"]*)"/)?.[1]?.trim() ?? '',
                logo: line.match(/tvg-logo="([^"]*)"/)?.[1]?.trim() ?? '',
                name: line.split(',').at(-1)?.trim() ?? '',
                tvgId: line.match(/tvg-id="([^"]*)"/)?.[1]?.trim() ?? '',
                tvgName: line.match(/tvg-name="([^"]*)"/)?.[1]?.trim() ?? '',
            };
            continue;
        }

        if (!pending || line.startsWith('#')) {
            continue;
        }

        items.push({
            ...pending,
            url: line,
        });
        pending = undefined;
    }

    return items.filter((item) => item.name.length > 0);
}

export async function createMutableTextServer(
    initialBody: string,
    options: {
        contentType?: string;
        resourcePath?: string;
    } = {}
): Promise<MutableTextServer> {
    const {
        contentType = 'text/plain; charset=utf-8',
        resourcePath = '/resource.txt',
    } = options;
    let body = initialBody;

    const server = createServer((req, res) => {
        const pathname = (req.url ?? '').split('?')[0];

        if (pathname !== resourcePath) {
            res.writeHead(404, {
                'Content-Type': 'application/json; charset=utf-8',
            });
            res.end(JSON.stringify({ error: 'Not found' }));
            return;
        }

        res.writeHead(200, { 'Content-Type': contentType });
        res.end(body);
    });

    await listenOnRandomPort(server);
    const address = server.address();

    if (!address || typeof address === 'string') {
        throw new Error('Failed to resolve the temporary HTTP server address.');
    }

    const origin = `http://127.0.0.1:${address.port}`;

    return {
        close: () => closeServer(server),
        origin,
        resourceUrl: `${origin}${resourcePath}`,
        setBody(nextBody: string) {
            body = nextBody;
        },
    };
}

export async function openWorkspaceSection(
    page: Page,
    label: string
): Promise<void> {
    if (label === 'Favorites') {
        await openPlaylistFavorites(page);
        return;
    }

    if (label === 'Recently viewed') {
        await openPlaylistRecent(page);
        return;
    }

    await page.getByRole('link', { name: label, exact: true }).click();
}

export async function openPlaylistFavorites(page: Page): Promise<void> {
    await openGlobalFavorites(page);
    await switchUnifiedCollectionScope(page, 'This playlist');
}

export async function openPlaylistRecent(page: Page): Promise<void> {
    await openGlobalRecent(page);
    await switchUnifiedCollectionScope(page, 'This playlist');
}

export async function openGlobalFavorites(page: Page): Promise<void> {
    await openWorkspaceSection(page, 'Global favorites');
    await page.waitForURL(/\/workspace\/global-favorites(?:\?.*)?$/);
}

export async function openGlobalRecent(page: Page): Promise<void> {
    const dialog = await openCommandPalette(page);

    await dialog.locator('input[type="search"]').fill('recent');
    await dialog.getByRole('button', { name: /Open recently viewed/i }).click();
    await page.waitForURL(/\/workspace\/global-recent(?:\?.*)?$/);
}

export async function switchUnifiedCollectionScope(
    page: Page,
    scopeLabel: 'This playlist' | 'All playlists'
): Promise<void> {
    const toggleGroup = page.locator('.scope-toggle');

    await expect(toggleGroup).toBeVisible();
    await clickButtonToggleOption(toggleGroup, scopeLabel);
}

export async function switchUnifiedCollectionContent(
    page: Page,
    contentLabel: 'Live TV' | 'Movies' | 'Series'
): Promise<void> {
    const toggleGroup = page.locator('.content-toggle');

    await expect(toggleGroup).toBeVisible();
    await clickButtonToggleOption(toggleGroup, contentLabel);
}

export async function clearCurrentUnifiedCollection(
    page: Page
): Promise<void> {
    await page
        .getByRole('button', {
            name: /Clear .* (favorites|recently viewed)/i,
        })
        .click();

    const dialog = page.locator('mat-dialog-container').last();
    await expect(dialog).toBeVisible();
    await dialog.getByRole('button', { name: /^Yes$/i }).click();
    await expect(dialog).toBeHidden();
}

async function clickButtonToggleOption(
    toggleGroup: Locator,
    label: string
): Promise<void> {
    const toggle = toggleGroup
        .locator('mat-button-toggle')
        .filter({ hasText: flexibleTextPattern(label) })
        .first();

    await expect(toggle).toBeVisible();

    if (!(await isButtonToggleSelected(toggle))) {
        await toggle.click();
    }

    await expect
        .poll(() => isButtonToggleSelected(toggle), {
            timeout: 10000,
        })
        .toBe(true);
}

export function channelItemByTitle(page: Page, title: string): Locator {
    return page.getByTestId('channel-item').filter({
        has: page.locator('.channel-name', {
            hasText: flexibleTextPattern(title),
        }),
    });
}

export function contentCardByTitle(page: Page, title: string): Locator {
    return page.locator('app-content-card').filter({
        has: page.locator('h3', {
            hasText: flexibleTextPattern(title),
        }),
    });
}

export async function expectVisibleContentCardTitle(
    page: Page,
    title: string
): Promise<void> {
    await expect
        .poll(
            async () => {
                const titles = await visibleContentCardTitles(page);
                return titles.some(
                    (visibleTitle) =>
                        normalizeVisibleText(visibleTitle) ===
                        normalizeVisibleText(title)
                );
            },
            { timeout: 20000 }
        )
        .toBe(true);
}

export async function expectPathname(
    page: Page,
    pattern: RegExp
): Promise<void> {
    await expect.poll(() => new URL(page.url()).pathname).toMatch(pattern);
}

export function playlistSwitcherTitle(page: Page): Locator {
    return page
        .locator('.playlist-switcher-trigger .playlist-info .name')
        .first();
}

export function gridListCardByTitle(page: Page, title: string): Locator {
    return page.locator('.category-content-layout mat-card').filter({
        has: page.locator('.title', {
            hasText: flexibleTextPattern(title),
        }),
    });
}

/**
 * Waits for the first grid card to appear (skeleton loading done) and returns
 * its display title. Use this instead of picking from fixture order, since the
 * grid sorts by date-desc and may paginate items off the first page.
 */
export async function waitForFirstGridListCardTitle(
    page: Page
): Promise<string> {
    const card = page.locator('.category-content-layout mat-card').first();
    await expect(card).toBeVisible({ timeout: 20000 });
    return ((await card.locator('.title').textContent()) ?? '').trim();
}

/**
 * Waits for the first grid card to appear with a non-empty title, clicks it,
 * and returns the title. Use this instead of waitForFirstGridListCardTitle +
 * clickGridListCardByTitle to avoid a race condition where the grid re-renders
 * between the title read and the subsequent search-by-title click.
 */
export async function clickFirstGridListCard(page: Page): Promise<string> {
    const card = page.locator('.category-content-layout mat-card').first();
    await expect(card).toBeVisible({ timeout: 20000 });
    const titleEl = card.locator('.title');
    let title = '';
    await expect(async () => {
        title = ((await titleEl.textContent()) ?? '').trim();
        expect(title.length).toBeGreaterThan(0);
    }).toPass({ timeout: 10000 });
    await card.click();
    return title;
}

export async function clickGridListCardByTitle(
    page: Page,
    title: string
): Promise<void> {
    const card = gridListCardByTitle(page, title).first();

    await expect(card).toBeVisible({ timeout: 20000 });
    await card.click();
}

export async function clickCategoryById(
    page: Page,
    categoryId: string
): Promise<void> {
    const category = page.locator(
        `app-workspace-context-panel .category-item[data-category-id="${categoryId}"]:visible`
    );

    await expect(category.first()).toBeVisible({ timeout: 20000 });
    await category.first().scrollIntoViewIfNeeded();
    await category.first().click();
    await expect
        .poll(async () => {
            const pathname = new URL(page.url()).pathname;
            const isSelected =
                (await category.first().getAttribute('aria-current')) ===
                'true';

            return (
                isSelected ||
                pathname.endsWith(`/${categoryId}`) ||
                pathname.includes(`/${categoryId}/`)
            );
        })
        .toBe(true);
}

export async function clickCategoryByNameExact(
    page: Page,
    categoryName: string
): Promise<void> {
    const categories = page
        .locator('app-workspace-context-panel .category-item:visible')
        .filter({
            has: page.locator('.nav-item-label', {
                hasText: new RegExp(`^\\s*${escapeRegex(categoryName)}\\s*$`),
            }),
        });
    const category = await pickPreferredCategory(categories);

    await expect(category).toBeVisible();
    await expect(category).toBeEnabled();
    await category.scrollIntoViewIfNeeded();
    const categoryId =
        (await category.getAttribute('data-category-id'))?.trim() ?? '';
    await category.click();
    const selectedCategory =
        categoryId.length > 0
            ? page
                  .locator(
                      `app-workspace-context-panel .category-item[data-category-id="${categoryId}"]:visible`
                  )
                  .first()
            : category;
    await expect(selectedCategory).toHaveAttribute('aria-current', 'true', {
        timeout: 20000,
    });
}

export function sourceRowByTitle(page: Page, title: string): Locator {
    return page.locator('app-playlist-item').filter({
        hasText: flexibleTextPattern(title),
    });
}

export async function getVisibleSourceTitles(page: Page): Promise<string[]> {
    return page.locator('app-playlist-item').evaluateAll((elements) =>
        elements
            .map((element) => {
                const titleElement = element.querySelector('.playlist-title');
                return titleElement?.textContent?.trim() ?? '';
            })
            .filter((title) => title.length > 0)
    );
}

export async function selectSourceTypeFilter(
    page: Page,
    typeLabel: 'All' | 'M3U' | 'Xtream' | 'Stalker'
): Promise<void> {
    await selectSourcesTypeFilterOption(page, typeLabel);
}

export async function selectSourceSort(
    page: Page,
    sortLabel:
        | 'Date added (Newest first)'
        | 'Date added (Oldest first)'
        | 'Name (A-Z)'
        | 'Name (Z-A)'
        | 'Custom order'
): Promise<void> {
    const sortTrigger = page.locator('app-workspace-sources .sort-trigger');

    await expect(sortTrigger).toBeVisible();
    await sortTrigger.click();

    const option = page
        .locator('.cdk-overlay-pane [role="menuitem"]')
        .filter({
            hasText: flexibleTextPattern(sortLabel),
        })
        .first();

    await expect(option).toBeVisible();
    await option.click();
    await expect(option).toBeHidden();
}

export async function dragSourceBefore(
    page: Page,
    sourceTitle: string,
    targetTitle: string
): Promise<void> {
    const source = sourceRowByTitle(page, sourceTitle).locator('.drag-icon');
    const target = sourceRowByTitle(page, targetTitle).locator(
        '.playlist-item'
    );

    await expect(source.first()).toBeVisible();
    await expect(target.first()).toBeVisible();

    const sourceBox = await source.first().boundingBox();
    const targetBox = await target.first().boundingBox();

    if (!sourceBox || !targetBox) {
        throw new Error('Could not resolve source or target bounds for drag.');
    }

    await page.mouse.move(
        sourceBox.x + sourceBox.width / 2,
        sourceBox.y + sourceBox.height / 2
    );
    await page.mouse.down();
    await page.mouse.move(
        targetBox.x + targetBox.width / 2,
        targetBox.y + targetBox.height / 3,
        {
            steps: 15,
        }
    );
    await page.mouse.up();
}

export async function openSourceEditor(
    page: Page,
    title: string
): Promise<Locator> {
    const row = sourceRowByTitle(page, title).first();

    await expect(row).toBeVisible();
    await row.locator('.edit-btn').click();
    const dialog = page.locator('mat-dialog-container').last();

    await expect(dialog).toBeVisible();
    return dialog;
}

export async function updateSourceDialog(
    dialog: Locator,
    updates: Partial<
        Record<
            | 'macAddress'
            | 'password'
            | 'portalUrl'
            | 'serverUrl'
            | 'stalkerDeviceId1'
            | 'stalkerDeviceId2'
            | 'stalkerSerialNumber'
            | 'stalkerSignature1'
            | 'stalkerSignature2'
            | 'title'
            | 'url'
            | 'userAgent'
            | 'username',
            string
        > & {
            autoRefresh: boolean;
        }
    >
): Promise<void> {
    for (const [field, value] of Object.entries(updates)) {
        if (field === 'autoRefresh' || value == null) {
            continue;
        }

        await setInputValue(
            dialog.locator(`input[formcontrolname="${field}"]`),
            value as string
        );
    }

    if (typeof updates.autoRefresh === 'boolean') {
        const checkbox = dialog.locator(
            'mat-checkbox[formcontrolname="autoRefresh"] input[type="checkbox"]'
        );

        if (updates.autoRefresh) {
            await checkbox.check();
        } else {
            await checkbox.uncheck();
        }
    }
}

export async function saveSourceDialog(
    page: Page,
    dialog: Locator
): Promise<void> {
    await dialog.getByRole('button', { name: 'Save', exact: true }).click();
    await page.waitForSelector('mat-dialog-container', { state: 'detached' });
    await expectPlaylistUpdatedToast(page);
}

export async function expectSourceDialogValues(
    dialog: Locator,
    expected: Partial<
        Record<
            | 'macAddress'
            | 'password'
            | 'portalUrl'
            | 'serverUrl'
            | 'stalkerDeviceId1'
            | 'stalkerDeviceId2'
            | 'stalkerSerialNumber'
            | 'stalkerSignature1'
            | 'stalkerSignature2'
            | 'title'
            | 'url'
            | 'userAgent'
            | 'username',
            string
        > & {
            autoRefresh: boolean;
        }
    >
): Promise<void> {
    for (const [field, value] of Object.entries(expected)) {
        if (field === 'autoRefresh' || value == null) {
            continue;
        }

        await expect(
            dialog.locator(`input[formcontrolname="${field}"]`)
        ).toHaveValue(value as string);
    }

    if (typeof expected.autoRefresh === 'boolean') {
        const checkbox = dialog.locator(
            'mat-checkbox[formcontrolname="autoRefresh"] input[type="checkbox"]'
        );

        if (expected.autoRefresh) {
            await expect(checkbox).toBeChecked();
        } else {
            await expect(checkbox).not.toBeChecked();
        }
    }
}

export async function deleteSource(page: Page, title: string): Promise<void> {
    const row = sourceRowByTitle(page, title).first();

    await expect(row).toBeVisible();
    await row.locator('.delete-btn').click();
    await confirmDialog(page);
}

export async function refreshSource(
    page: Page,
    title: string,
    options: {
        confirm?: boolean;
    } = {}
): Promise<void> {
    const { confirm = false } = options;
    const row = sourceRowByTitle(page, title).first();

    await expect(row).toBeVisible();
    await row.locator('.refresh-btn').click();

    if (confirm) {
        await confirmDialog(page);
    }
}

export async function waitForSourceRowIdle(
    page: Page,
    title: string
): Promise<void> {
    const row = sourceRowByTitle(page, title).first();

    await expect(row).toBeVisible();
    await expect(row.locator('.busy-state__message')).toHaveCount(0, {
        timeout: 60000,
    });
    await expect(row.locator('.action-spinner')).toHaveCount(0, {
        timeout: 60000,
    });
}

export async function waitForM3uCatalog(page: Page): Promise<void> {
    await page.waitForURL(/\/workspace\/playlists\/.+\/all$/);
    await expect(page.getByTestId('channel-item').first()).toBeVisible({
        timeout: 20000,
    });
}

export async function waitForXtreamCatalog(page: Page): Promise<void> {
    await page.waitForURL(/\/workspace\/xtreams\/.+/);
    await waitForXtreamImportToFinish(page);

    const categories = page.locator(
        'app-workspace-context-panel .category-item'
    );
    await expect(categories.first()).toBeVisible({ timeout: 20000 });

    const contentItems = page.locator(
        '.content-card, [data-test-id="channel-item"], mat-card'
    );

    try {
        await expect(contentItems.first()).toBeVisible({ timeout: 5000 });
        return;
    } catch {
        const category = await pickPreferredCategory(categories);
        await category.scrollIntoViewIfNeeded();
        await category.click();
        await expect(contentItems.first()).toBeVisible({ timeout: 20000 });
    }
}

export async function waitForXtreamWorkspaceReady(page: Page): Promise<void> {
    await waitForXtreamCatalog(page);
}

export async function expectPlaylistUpdatedToast(page: Page): Promise<void> {
    await expect(
        page
            .locator('.mat-mdc-snack-bar-label')
            .filter({
                hasText: 'Success! The playlist was successfully updated.',
            })
            .last()
    ).toBeVisible({
        timeout: 20000,
    });
}

export async function waitForXtreamImportToFinish(page: Page): Promise<void> {
    const overlay = page.locator('.workspace-loading-overlay');

    try {
        await overlay.waitFor({ state: 'visible', timeout: 5000 });
    } catch {
        // The overlay may already be gone by the time the test reaches this point.
    }

    await expect(overlay).toHaveCount(0, { timeout: 30000 });
}

async function pickPreferredCategory(categories: Locator): Promise<Locator> {
    const count = await categories.count();
    let fallback: Locator | null = null;

    for (let index = 0; index < count; index += 1) {
        const candidate = categories.nth(index);

        if (!(await candidate.isVisible())) {
            continue;
        }

        fallback ??= candidate;

        const countText =
            (await candidate.locator('.item-count').first().textContent()) ??
            '';
        const itemCount = Number.parseInt(countText.trim(), 10);

        if (Number.isFinite(itemCount) && itemCount > 0) {
            return candidate;
        }
    }

    return fallback ?? categories.first();
}

export async function waitForStalkerCatalog(page: Page): Promise<void> {
    await page.waitForURL(/\/workspace\/stalker\/.+/);

    const categories = page.locator('.category-item');
    await expect(categories.first()).toBeVisible({ timeout: 20000 });
    await categories.first().click();

    const contentItems = page.locator(
        '.content-card, [data-test-id="channel-item"], mat-card'
    );
    await expect(contentItems.first()).toBeVisible({ timeout: 20000 });
}

export async function fillWorkspaceSearch(
    page: Page,
    term: string,
    options: {
        submit?: boolean;
    } = {}
): Promise<void> {
    const input = page.locator(
        'app-workspace-shell-header .search-field input[type="search"]'
    );

    await expect(input).toBeEnabled();
    await input.fill(term);

    if (options.submit) {
        await input.press('Enter');
    }
}

export async function expectWorkspaceSearchScope(
    page: Page,
    expected: RegExp | string
): Promise<void> {
    await expect(
        page.locator('app-workspace-shell-header .search-chip--scope')
    ).toHaveText(expected);
}

export async function expectWorkspaceSearchStatus(
    page: Page,
    expected: RegExp | string
): Promise<void> {
    await expect(
        page.locator('app-workspace-shell-header .search-chip--status')
    ).toHaveText(expected);
}

async function startPortalDebugCapture(page: Page): Promise<void> {
    await page.evaluate(() => {
        const electronApi = window.electron as typeof window.electron & {
            onPortalDebugEvent?: (
                callback: (event: PortalDebugEvent) => void
            ) => (() => void) | undefined;
        };

        window.__portalDebugUnsubscribe?.();
        window.__portalDebugEvents = [];

        if (!electronApi.onPortalDebugEvent) {
            return;
        }

        window.__portalDebugUnsubscribe = electronApi.onPortalDebugEvent(
            (event: PortalDebugEvent) => {
                window.__portalDebugEvents?.push(event as PortalDebugEvent);
            }
        );
    });
}

async function startDbOperationCapture(page: Page): Promise<void> {
    await page.evaluate(() => {
        const electronApi = window.electron as typeof window.electron & {
            onDbOperationEvent?: (
                callback: (event: DbOperationEvent) => void
            ) => (() => void) | undefined;
        };

        window.__dbOperationUnsubscribe?.();
        window.__dbOperationEvents = [];

        if (!electronApi.onDbOperationEvent) {
            return;
        }

        window.__dbOperationUnsubscribe = electronApi.onDbOperationEvent(
            (event: DbOperationEvent) => {
                window.__dbOperationEvents?.push(event);
            }
        );
    });
}

async function startRendererFrameCapture(page: Page): Promise<void> {
    await page.evaluate(() => {
        if (window.__rendererFrameRequestId) {
            window.cancelAnimationFrame(window.__rendererFrameRequestId);
        }

        window.__rendererFrameCount = 0;

        const tick = () => {
            window.__rendererFrameCount =
                (window.__rendererFrameCount ?? 0) + 1;
            window.__rendererFrameRequestId =
                window.requestAnimationFrame(tick);
        };

        window.__rendererFrameRequestId = window.requestAnimationFrame(tick);
    });
}

export async function expectPortalDebugSuccess(
    page: Page,
    provider: PortalProvider
): Promise<void> {
    await expect
        .poll(
            async () => {
                return page.evaluate((targetProvider) => {
                    return (
                        window.__portalDebugEvents?.filter(
                            (event) =>
                                event.provider === targetProvider &&
                                event.status === 'success' &&
                                event.transport === 'electron-main'
                        ).length ?? 0
                    );
                }, provider);
            },
            { timeout: 20000 }
        )
        .toBeGreaterThan(0);
}

export async function waitForPortalDebugEvent(
    page: Page,
    options: {
        operation: string;
        predicate?: (event: PortalDebugEvent) => boolean;
        provider: PortalProvider;
        timeoutMs?: number;
    }
): Promise<PortalDebugEvent> {
    const { operation, predicate, provider, timeoutMs = 20000 } = options;
    const startedAt = Date.now();

    while (Date.now() - startedAt <= timeoutMs) {
        const events = await page.evaluate(
            () => window.__portalDebugEvents ?? []
        );
        const match = events.find((event) => {
            if (
                event.provider !== provider ||
                event.operation !== operation ||
                event.status !== 'success' ||
                event.transport !== 'electron-main'
            ) {
                return false;
            }

            return predicate ? predicate(event) : true;
        });

        if (match) {
            return match;
        }

        await page.waitForTimeout(200);
    }

    const recentEvents = await page.evaluate(() =>
        (window.__portalDebugEvents ?? []).slice(-10)
    );

    throw new Error(
        `Timed out waiting for ${provider}:${operation}. Recent events: ${JSON.stringify(
            recentEvents,
            null,
            2
        )}`
    );
}

export async function waitForDbOperationEvent(
    page: Page,
    options: {
        operation: string;
        operationId?: string;
        phase?: string;
        playlistId?: string;
        predicate?: (event: DbOperationEvent) => boolean;
        status?: DbOperationEvent['status'];
        timeoutMs?: number;
    }
): Promise<DbOperationEvent> {
    const {
        operation,
        operationId,
        phase,
        playlistId,
        predicate,
        status,
        timeoutMs = 20000,
    } = options;
    const startedAt = Date.now();

    while (Date.now() - startedAt <= timeoutMs) {
        const events = await page.evaluate(
            () => window.__dbOperationEvents ?? []
        );
        const match = events.find((event) => {
            if (event.operation !== operation) {
                return false;
            }

            if (operationId && event.operationId !== operationId) {
                return false;
            }

            if (status && event.status !== status) {
                return false;
            }

            if (phase && event.phase !== phase) {
                return false;
            }

            if (playlistId && event.playlistId !== playlistId) {
                return false;
            }

            return predicate ? predicate(event) : true;
        });

        if (match) {
            return match;
        }

        await page.waitForTimeout(200);
    }

    const recentEvents = await page.evaluate(() =>
        (window.__dbOperationEvents ?? []).slice(-20)
    );

    throw new Error(
        `Timed out waiting for DB event ${operation}. Recent events: ${JSON.stringify(
            recentEvents,
            null,
            2
        )}`
    );
}

export async function getRendererFrameCount(page: Page): Promise<number> {
    return page.evaluate(() => window.__rendererFrameCount ?? 0);
}

export async function expectRendererFramesAdvance(
    page: Page,
    options: {
        minimumDelta?: number;
        sampleMs?: number;
    } = {}
): Promise<void> {
    const { minimumDelta = 4, sampleMs = 300 } = options;
    const startCount = await getRendererFrameCount(page);

    await page.waitForTimeout(sampleMs);

    await expect
        .poll(async () => (await getRendererFrameCount(page)) - startCount, {
            timeout: sampleMs + 1500,
        })
        .toBeGreaterThanOrEqual(minimumDelta);
}

export async function resetMockServers(
    request: APIRequestContext,
    providers: PortalProvider[]
): Promise<void> {
    for (const provider of providers) {
        const server =
            provider === 'stalker' ? stalkerMockServer : xtreamMockServer;
        const response = await request.post(`${server}/reset`);
        expect(response.ok()).toBeTruthy();
    }
}

async function openCommandPalette(page: Page): Promise<Locator> {
    const modifier = process.platform === 'darwin' ? 'Meta' : 'Control';
    await page.locator('body').focus();
    await page.keyboard.press(`${modifier}+K`);
    const dialog = page.locator(
        'mat-dialog-container app-workspace-command-palette'
    );

    await expect(dialog).toBeVisible();
    return dialog;
}

async function confirmDialog(page: Page, buttonLabel = 'Yes'): Promise<void> {
    const dialog = page.locator('mat-dialog-container');

    await expect(dialog).toBeVisible();
    await dialog
        .getByRole('button', { name: buttonLabel, exact: true })
        .click();
    await page.waitForSelector('mat-dialog-container', { state: 'detached' });
}

async function selectSourcesTypeFilterOption(
    page: Page,
    label: string
): Promise<void> {
    const option = page
        .locator('app-workspace-sources-filters-panel .option-row')
        .filter({
            has: page.locator('.option-label', {
                hasText: flexibleTextPattern(label),
            }),
        })
        .first();

    await expect(option).toBeVisible();
    await option.click();
}

async function listenOnRandomPort(server: Server): Promise<void> {
    await new Promise<void>((resolvePromise, reject) => {
        server.once('error', reject);
        server.listen(0, '127.0.0.1', () => {
            server.off('error', reject);
            resolvePromise();
        });
    });
}

async function closeServer(server: Server): Promise<void> {
    await new Promise<void>((resolvePromise, reject) => {
        server.close((error) => {
            if (error) {
                reject(error);
                return;
            }

            resolvePromise();
        });
    });
}

function escapeRegex(value: string): string {
    return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}

function flexibleTextPattern(value: string): RegExp {
    return new RegExp(
        value
            .trim()
            .split(/\s+/)
            .map((part) => escapeRegex(part))
            .join('\\s+'),
        'i'
    );
}

async function isButtonToggleSelected(toggle: Locator): Promise<boolean> {
    try {
        return await toggle.evaluate((element) => {
            const host = element as HTMLElement;
            const selectedDescendant = host.querySelector(
                '[aria-checked="true"], [aria-pressed="true"]'
            );

            return (
                host.classList.contains('mat-button-toggle-checked') ||
                host.getAttribute('aria-checked') === 'true' ||
                host.getAttribute('aria-pressed') === 'true' ||
                selectedDescendant !== null
            );
        });
    } catch {
        return false;
    }
}

async function visibleContentCardTitles(page: Page): Promise<string[]> {
    return page
        .locator('app-content-card h3')
        .allInnerTexts()
        .then((titles) => titles.map((title) => title.trim()).filter(Boolean));
}

function normalizeVisibleText(value: string): string {
    return value.trim().replace(/\s+/g, ' ').toLowerCase();
}
