import JSZip from 'jszip';
import path from 'path';
import {expect, test} from '@playwright/test';
import {expectExternalNavigate, globalDataRequests, limitRequests, mockApi, responseFixtures} from '@tryghost/admin-x-framework/test/acceptance';
import {readFileSync} from 'fs';
import type {Page} from '@playwright/test';

const themeEditorZip = readFileSync(path.join(__dirname, '../../utils/responses/theme.zip'));

const customThemesLimitConfig = (allowlist: string[], error: string) => ({
    ...globalDataRequests.browseConfig,
    response: {
        config: {
            ...responseFixtures.config.config,
            hostSettings: {
                limits: {
                    customThemes: {
                        allowlist,
                        error
                    }
                }
            }
        }
    }
});

const themeDownloadRequest = (themeName: string) => ({
    method: 'GET' as const,
    path: `/themes/${themeName}/download/`,
    response: '',
    rawResponse: themeEditorZip,
    responseHeaders: {'content-type': 'application/zip'}
});

const createArchiveBuffer = async (build: (zip: JSZip) => void) => {
    const zip = new JSZip();

    build(zip);

    return Buffer.from(await zip.generateAsync({type: 'uint8array'}));
};

const escapeRegExp = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');

async function openChangeThemeModal(page: Page) {
    await page.goto('/#/settings/theme');
    await page.getByTestId('theme').getByRole('button', {name: 'Change theme'}).click();

    return page.getByTestId('theme-modal');
}

async function openInstalledThemeEditor(page: Page, themeName: string) {
    const modal = await openChangeThemeModal(page);
    await modal.getByRole('tab', {name: 'Installed'}).click();

    const themeListItem = modal.getByTestId('theme-list-item').filter({hasText: new RegExp(escapeRegExp(themeName), 'i')});
    await themeListItem.getByRole('button', {name: 'Menu'}).click();
    await page.getByTestId('popover-content').getByRole('button', {name: 'Edit code'}).click();

    return page.getByTestId('theme-code-editor-modal');
}

async function openActiveThemeEditorFromSettings(page: Page) {
    await page.goto('/#/settings');

    const themeSection = page.getByTestId('theme');
    await themeSection.getByRole('button', {name: 'Menu'}).click();
    await page.getByTestId('popover-content').getByRole('button', {name: 'Edit code'}).click();

    return page.getByTestId('theme-code-editor-modal');
}

test.describe('Theme settings', async () => {
    test('Browsing and installing default themes', async ({page}) => {
        const {lastApiRequests} = await mockApi({page, requests: {
            ...globalDataRequests,
            ...limitRequests,
            browseThemes: {method: 'GET', path: '/themes/', response: responseFixtures.themes},
            installTheme: {method: 'POST', path: /^\/themes\/install\/\?/, response: {
                themes: [{
                    name: 'edition',
                    package: {},
                    active: false,
                    templates: []
                }]
            }},
            activateTheme: {method: 'PUT', path: '/themes/casper/activate/', response: {
                themes: [{
                    ...responseFixtures.themes.themes.find(theme => theme.name === 'casper')!,
                    active: true
                }]
            }},
            activeTheme: {
                method: 'GET',
                path: '/themes/active/',
                response: {
                    themes: [{
                        name: 'edition',
                        package: {},
                        active: true,
                        templates: []
                    }]
                }
            }
        }});

        await page.goto('/#/settings/theme');

        const themeSection = page.getByTestId('theme');

        // Edition is the active theme
        await expect(themeSection).toHaveText(/edition/i);

        await themeSection.getByRole('button', {name: 'Change theme'}).click();

        const modal = page.getByTestId('theme-modal');

        // 1. Activate Casper (Edition is currently active)
        await modal.getByRole('button', {name: /Casper/}).click();
        await expect(modal.getByRole('button', {name: 'Activate Casper'})).toBeVisible();

        await modal.getByRole('button', {name: 'Activate Casper'}).click();
        await expect(page.getByTestId('confirmation-modal')).toHaveText(/activate/);
        await page.getByTestId('confirmation-modal').getByRole('button', {name: 'Activate'}).click();
        await expect(page.getByTestId('toast-success')).toHaveText(/casper is now your active theme/);

        // 2. Re-open themes list after activation
        await page.goto('/#/settings/theme');
        await page.getByTestId('theme').getByRole('button', {name: 'Change theme'}).click();
        const reopenedModal = page.getByTestId('theme-modal');

        // 3. Try to update Edition (which is already installed, should trigger overwrite)
        await reopenedModal.getByRole('button', {name: /Edition/}).click();
        await reopenedModal.getByRole('button', {name: 'Update Edition'}).click();

        // Should show overwrite confirmation
        const overwriteModal = page.getByTestId('confirmation-modal');
        await expect(overwriteModal).toBeVisible();
        await expect(overwriteModal).toHaveText(/overwrite/i);

        // Test Cancel button behavior
        await overwriteModal.getByRole('button', {name: 'Cancel'}).click();

        // Verify the overwrite modal is closed
        await expect(overwriteModal).not.toBeVisible();

        // Verify the theme modal is still open
        await expect(reopenedModal).toBeVisible();

        // Verify we're still on the Edition theme preview
        await expect(reopenedModal).toHaveText(/Edition/);
        await expect(reopenedModal.getByRole('button', {name: 'Update Edition'})).toBeVisible();

        // Install Edition again and overwrite it
        await reopenedModal.getByRole('button', {name: 'Update Edition'}).click();
        await overwriteModal.getByRole('button', {name: 'Overwrite'}).click();

        // Verify that Casper activation was called
        expect(lastApiRequests.activateTheme?.url).toMatch(/\/themes\/casper\/activate/);
        expect(lastApiRequests.installTheme?.url).toMatch(/\?source=github&ref=TryGhost%2FEdition/);
    });

    test('Managing installed themes', async ({page}) => {
        const {lastApiRequests} = await mockApi({page, requests: {
            ...globalDataRequests,
            browseThemes: {method: 'GET', path: '/themes/', response: responseFixtures.themes},
            activateTheme: {method: 'PUT', path: '/themes/casper/activate/', response: {
                themes: [{
                    ...responseFixtures.themes.themes.find(theme => theme.name === 'casper')!,
                    active: true
                }]
            }},
            deleteTheme: {method: 'DELETE', path: '/themes/edition/', response: {}}
        }});

        await page.goto('/#/settings/theme');

        const themeSection = page.getByTestId('theme');

        await themeSection.getByRole('button', {name: 'Change theme'}).click();

        const modal = page.getByTestId('theme-modal');

        await modal.getByRole('tab', {name: 'Installed'}).click();

        await expect(modal.getByTestId('theme-list-item')).toHaveCount(2);

        const casper = modal.getByTestId('theme-list-item').filter({hasText: /casper/});
        const edition = modal.getByTestId('theme-list-item').filter({hasText: /edition/});

        // Activate the inactive theme

        await expect(casper.getByRole('button', {name: 'Activate'})).toBeVisible();
        await expect(edition).toHaveText(/Active/);

        await casper.getByRole('button', {name: 'Activate'}).click();

        await expect(casper).toHaveText(/Active/);
        await expect(edition.getByRole('button', {name: 'Activate'})).toBeVisible();

        expect(lastApiRequests.activateTheme?.url).toMatch(/\/themes\/casper\/activate\//);

        // Download the active theme

        await casper.getByRole('button', {name: 'Menu'}).click();
        await page.getByTestId('popover-content').getByRole('button', {name: 'Download'}).click();

        await expect(page.locator('iframe#iframeDownload')).toHaveAttribute('src', /\/api\/admin\/themes\/casper\/download/);

        // Delete the inactive theme

        await edition.getByRole('button', {name: 'Menu'}).click();
        await page.getByTestId('popover-content').getByRole('button', {name: 'Delete'}).click();

        const confirmation = page.getByTestId('confirmation-modal');
        await confirmation.getByRole('button', {name: 'Delete'}).click();

        await expect(modal.getByTestId('theme-list-item')).toHaveCount(1);

        expect(lastApiRequests.deleteTheme?.url).toMatch(/\/themes\/edition\/$/);
    });

    test('Uploading a new theme', async ({page}) => {
        const {lastApiRequests} = await mockApi({page, requests: {
            ...globalDataRequests,
            browseThemes: {method: 'GET', path: '/themes/', response: responseFixtures.themes},
            uploadTheme: {method: 'POST', path: '/themes/upload/', response: {
                themes: [{
                    name: 'mytheme',
                    package: {},
                    active: false,
                    templates: []
                }]
            }}
        }});

        await page.goto('/#/settings/theme');

        const themeSection = page.getByTestId('theme');

        await themeSection.getByRole('button', {name: 'Change theme'}).click();

        const modal = page.getByTestId('theme-modal');

        await modal.getByRole('button', {name: 'Upload theme'}).click();

        const fileChooserPromise = page.waitForEvent('filechooser');

        await page.getByTestId('confirmation-modal').locator('label[for=theme-upload]').click();

        const fileChooser = await fileChooserPromise;
        await fileChooser.setFiles(`${__dirname}/../../utils/responses/theme.zip`);

        await expect(page.getByTestId('confirmation-modal')).toHaveText(/successful/);

        await expect(modal.getByTestId('theme-list-item')).toHaveCount(3);

        expect(lastApiRequests.uploadTheme).toBeTruthy();
    });

    test('Supports editing and saving a custom theme in browser', async ({page}) => {
        const {lastApiRequests} = await mockApi({page, requests: {
            ...globalDataRequests,
            browseThemes: {method: 'GET', path: '/themes/', response: responseFixtures.themes},
            downloadTheme: themeDownloadRequest('edition'),
            uploadTheme: {
                method: 'POST',
                path: '/themes/upload/',
                response: {
                    themes: [{
                        name: 'edition',
                        package: {
                            name: 'Edition',
                            version: '1.0.0'
                        },
                        active: true,
                        templates: []
                    }]
                }
            }
        }});

        const editorModal = await openInstalledThemeEditor(page, 'edition');
        await expect(editorModal).toBeVisible();
        await expect(editorModal).toContainText('Edit theme');
        await expect(editorModal).toContainText('edition');

        const codeEditor = editorModal.locator('.cm-content');
        await codeEditor.click();
        await page.keyboard.press('ControlOrMeta+A');
        await page.keyboard.insertText('{"name":"edition","version":"1.0.0"}\n');

        await editorModal.getByRole('button', {name: 'Save'}).click();
        await page.getByTestId('theme-editor-confirm-modal').getByRole('button', {name: 'Replace theme'}).click();

        await expect(page.getByTestId('toast-success')).toHaveText(/Theme saved/i);
        expect(lastApiRequests.downloadTheme?.url).toMatch(/\/themes\/edition\/download/);
        expect(lastApiRequests.uploadTheme?.url).toMatch(/\/themes\/upload\//);
    });

    test('Saves via the Cmd+S keyboard shortcut', async ({page}) => {
        const {lastApiRequests} = await mockApi({page, requests: {
            ...globalDataRequests,
            browseThemes: {method: 'GET', path: '/themes/', response: responseFixtures.themes},
            downloadTheme: themeDownloadRequest('edition'),
            uploadTheme: {
                method: 'POST',
                path: '/themes/upload/',
                response: {
                    themes: [{
                        name: 'edition',
                        package: {name: 'Edition', version: '1.0.0'},
                        active: true,
                        templates: []
                    }]
                }
            }
        }});

        const editorModal = await openInstalledThemeEditor(page, 'edition');

        const codeEditor = editorModal.locator('.cm-content');
        await codeEditor.click();
        await page.keyboard.press('ControlOrMeta+A');
        await page.keyboard.insertText('{"name":"edition","version":"1.0.0"}\n');

        // Pressing Cmd+S (or Ctrl+S) should run the same save flow as
        // clicking Save, with the listener reading the freshest handleSave
        // through the latest-ref pattern. If the ref ever got out of sync,
        // the save would see stale state (no changes) and abort with the
        // "No changes to save" toast instead of opening this confirmation.
        await page.keyboard.press('ControlOrMeta+S');
        await page.getByTestId('theme-editor-confirm-modal').getByRole('button', {name: 'Replace theme'}).click();

        await expect(page.getByTestId('toast-success')).toHaveText(/Theme saved/i);
        expect(lastApiRequests.uploadTheme?.url).toMatch(/\/themes\/upload\//);
    });

    test('Surfaces server-side upload size-limit errors with a user-friendly toast', async ({page}) => {
        await mockApi({page, requests: {
            ...globalDataRequests,
            browseThemes: {method: 'GET', path: '/themes/', response: responseFixtures.themes},
            downloadTheme: themeDownloadRequest('edition'),
            uploadTheme: {
                method: 'POST',
                path: '/themes/upload/',
                responseStatus: 415,
                response: {
                    errors: [{
                        message: 'Zip entry exceeds maximum uncompressed size.',
                        errorType: 'UnsupportedMediaTypeError',
                        code: 'ENTRY_TOO_LARGE',
                        errorDetails: {
                            entryName: 'partials/huge.hbs',
                            observedBytes: 2_000_000,
                            limitBytes: 1_048_576
                        }
                    }]
                }
            }
        }});

        const editorModal = await openInstalledThemeEditor(page, 'edition');
        const codeEditor = editorModal.locator('.cm-content');
        await codeEditor.click();
        await page.keyboard.press('ControlOrMeta+A');
        await page.keyboard.insertText('{"name":"edition","version":"1.0.0"}\n');

        await editorModal.getByRole('button', {name: 'Save'}).click();
        await page.getByTestId('theme-editor-confirm-modal').getByRole('button', {name: 'Replace theme'}).click();

        // The toast should mention the offending file and the limit, not just
        // a generic "something went wrong" message.
        const errorToast = page.getByTestId('toast-error');
        await expect(errorToast).toContainText(/partials\/huge\.hbs/);
        await expect(errorToast).toContainText(/1\.0 MB/);
        await expect(errorToast).not.toContainText(/something went wrong/i);
    });

    test('Loads CodeMirror with the dynamic language extension applied', async ({page}) => {
        await mockApi({page, requests: {
            ...globalDataRequests,
            browseThemes: {method: 'GET', path: '/themes/', response: responseFixtures.themes},
            downloadTheme: themeDownloadRequest('edition')
        }});

        const editorModal = await openInstalledThemeEditor(page, 'edition');

        // The CodeMirror surface only renders after the dynamic language
        // module import resolves and the extensions array is set. Proving
        // the editor accepts input verifies the success branch of the load
        // chain — if extensions never landed, the surface would be inert.
        const codeEditor = editorModal.locator('.cm-content');
        await expect(codeEditor).toBeVisible();
        await expect(editorModal).toContainText(/json/i);

        await codeEditor.click();
        await page.keyboard.press('ControlOrMeta+A');
        await page.keyboard.insertText('{"name":"edition","version":"1.0.0"}\n');

        await expect(editorModal).toContainText(/1 file modified/);
    });

    test('Saves built-in themes as a new theme name', async ({page}) => {
        await mockApi({page, requests: {
            ...globalDataRequests,
            browseThemes: {method: 'GET', path: '/themes/', response: responseFixtures.themes},
            downloadTheme: themeDownloadRequest('casper'),
            uploadTheme: {
                method: 'POST',
                path: '/themes/upload/',
                response: {
                    themes: [{
                        name: 'casper-edited',
                        package: {
                            name: 'Casper Edited',
                            version: '1.0.0'
                        },
                        active: false,
                        templates: []
                    }]
                }
            }
        }});

        const editorModal = await openInstalledThemeEditor(page, 'casper');
        await expect(editorModal).toBeVisible();

        const codeEditor = editorModal.locator('.cm-content');
        await codeEditor.click();
        await page.keyboard.press('ControlOrMeta+A');
        await page.keyboard.insertText('{"name":"casper","version":"1.0.0"}\n');

        await editorModal.getByRole('button', {name: 'Save'}).click();
        const inputModal = page.getByTestId('theme-editor-input-modal');
        await inputModal.getByLabel('Theme name').fill('casper-edited');
        await inputModal.getByRole('button', {name: 'Continue'}).click();
        await page.getByTestId('theme-editor-confirm-modal').getByRole('button', {name: 'Save theme'}).click();

        await expect(page).toHaveURL(/#\/settings\/theme\/edit\/casper-edited/);
        await expect(editorModal).toContainText('casper-edited');
    });

    test('Rejects invalid theme names in the save-as flow', async ({page}) => {
        await mockApi({page, requests: {
            ...globalDataRequests,
            browseThemes: {method: 'GET', path: '/themes/', response: responseFixtures.themes},
            downloadTheme: themeDownloadRequest('casper')
        }});

        const editorModal = await openInstalledThemeEditor(page, 'casper');

        const codeEditor = editorModal.locator('.cm-content');
        await codeEditor.click();
        await page.keyboard.press('ControlOrMeta+A');
        await page.keyboard.insertText('{"name":"casper","version":"1.0.0"}\n');

        const inputModal = page.getByTestId('theme-editor-input-modal');

        // Disallowed characters (spaces, punctuation, leading dots, etc.)
        // are caught by the new format check and surfaced as a clear toast
        // instead of being silently renamed by the server's sanitiser.
        await editorModal.getByRole('button', {name: 'Save'}).click();
        await inputModal.getByLabel('Theme name').fill('Foo Bar!');
        await inputModal.getByRole('button', {name: 'Continue'}).click();
        await expect(page.getByText(/Invalid theme name/i)).toBeVisible();

        // Default theme names go through a separate guard with a more
        // specific error message that should keep surfacing on its own.
        await editorModal.getByRole('button', {name: 'Save'}).click();
        await inputModal.getByLabel('Theme name').fill('casper');
        await inputModal.getByRole('button', {name: 'Continue'}).click();
        await expect(page.getByText(/Built-in themes cannot be overwritten/i)).toBeVisible();
    });

    test('Limits uploading new themes and redirect to /pro', async ({page}) => {
        await mockApi({page, requests: {
            ...globalDataRequests,
            ...limitRequests,
            browseThemes: {method: 'GET', path: '/themes/', response: responseFixtures.themes},
            browseConfig: {
                ...globalDataRequests.browseConfig,
                response: {
                    config: {
                        ...responseFixtures.config.config,
                        hostSettings: {
                            limits: {
                                customThemes: {
                                    allowlist: ['casper', 'source', 'edition'],
                                    error: 'Upgrade to enable custom themes'
                                }
                            }
                        }
                    }
                }
            }
        }});

        await page.goto('/#/settings/theme');

        const themeSection = page.getByTestId('theme');

        await themeSection.getByRole('button', {name: 'Change theme'}).click();

        const modal = page.getByTestId('theme-modal');

        await modal.getByRole('button', {name: 'Upload theme'}).click();

        // Wait for limit modal to appear
        await page.waitForSelector('[data-testid="limit-modal"]', {timeout: 10000});

        await expect(page.getByTestId('limit-modal')).toHaveText(/Upgrade to enable custom themes/);

        const limitModal = page.getByTestId('limit-modal');

        await limitModal.getByRole('button', {name: 'Upgrade'}).click();

        await expectExternalNavigate(page, {route: '/pro'});
    });

    test('Prevents overwriting the default theme', async ({page}) => {
        await mockApi({page, requests: {
            ...globalDataRequests,
            browseThemes: {method: 'GET', path: '/themes/', response: responseFixtures.themes},
            uploadTheme: {method: 'POST', path: '/themes/upload/', response: {
                themes: [{
                    name: 'mytheme',
                    package: {},
                    active: false,
                    templates: []
                }]
            }}
        }});

        await page.goto('/#/settings/theme');

        const themeSection = page.getByTestId('theme');

        await themeSection.getByRole('button', {name: 'Change theme'}).click();

        const modal = page.getByTestId('theme-modal');

        await modal.getByRole('button', {name: 'Upload theme'}).click();

        const fileChooserPromise = page.waitForEvent('filechooser');

        await page.getByTestId('confirmation-modal').locator('label[for=theme-upload]').click();

        const fileChooser = await fileChooserPromise;
        await fileChooser.setFiles(`${__dirname}/../../utils/responses/source.zip`);

        await expect(page.getByTestId('confirmation-modal')).toHaveText(/Upload failed/);
    });

    test('fires Install Theme modal when redirected from marketplace url', async ({page}) => {
        // Positive test: Taste is in the allowlist
        await mockApi({page, requests: {
            ...globalDataRequests,
            ...limitRequests,
            browseThemes: {method: 'GET', path: '/themes/', response: responseFixtures.themes},
            browseConfig: {
                ...globalDataRequests.browseConfig,
                response: {
                    config: {
                        ...responseFixtures.config.config,
                        hostSettings: {
                            limits: {
                                customThemes: {
                                    allowlist: ['casper', 'headline', 'taste'], // Taste IS in the allowlist
                                    error: 'Upgrade to use more themes'
                                }
                            }
                        }
                    }
                }
            }
        }});
        await page.goto('/#/settings/theme/install?source=github&ref=TryGhost/Taste');

        await page.waitForSelector('[data-testid="theme-modal"]');

        const confirmation = page.getByTestId('confirmation-modal');

        await expect(confirmation).toHaveText(/Install Theme/);
        await expect(confirmation).toHaveText(/By clicking below, Taste will automatically be activated as the theme for your site/);
    });

    test('Prevents changing theme when only one theme is allowed', async ({page}) => {
        await mockApi({page, requests: {
            ...globalDataRequests,
            ...limitRequests,
            browseThemes: {method: 'GET', path: '/themes/', response: responseFixtures.themes},
            browseConfig: {
                ...globalDataRequests.browseConfig,
                response: {
                    config: {
                        ...responseFixtures.config.config,
                        hostSettings: {
                            limits: {
                                customThemes: {
                                    allowlist: ['one-theme-only'],
                                    error: 'Upgrade to use custom themes'
                                }
                            }
                        }
                    }
                }
            }
        }});

        await page.goto('/#/settings/theme');

        const themeSection = page.getByTestId('theme');

        // Wait for the theme section to be ready
        await expect(themeSection).toBeVisible();

        // Wait for the Change theme button to be ready (component needs to check limits first)
        const changeThemeButton = themeSection.getByRole('button', {name: 'Change theme'});
        await expect(changeThemeButton).toBeVisible();
        await expect(changeThemeButton).toBeEnabled();

        await changeThemeButton.click();

        // Should show limit modal instead of theme modal
        // Wait for the limit modal to appear (async limit check needs time)
        await page.waitForSelector('[data-testid="limit-modal"]', {timeout: 10000});

        await expect(page.getByTestId('limit-modal')).toBeVisible();
        await expect(page.getByTestId('limit-modal')).toHaveText(/Upgrade to use custom themes/);

        // Theme modal should not be visible
        await expect(page.getByTestId('theme-modal')).not.toBeVisible();

        const limitModal = page.getByTestId('limit-modal');
        await limitModal.getByRole('button', {name: 'Upgrade'}).click();

        await expectExternalNavigate(page, {route: '/pro'});
    });

    test('Allows changing theme when multiple themes are allowed', async ({page}) => {
        await mockApi({page, requests: {
            ...globalDataRequests,
            ...limitRequests,
            browseThemes: {method: 'GET', path: '/themes/', response: responseFixtures.themes},
            browseConfig: {
                ...globalDataRequests.browseConfig,
                response: {
                    config: {
                        ...responseFixtures.config.config,
                        hostSettings: {
                            limits: {
                                customThemes: {
                                    allowlist: ['casper', 'headline', 'edition'],
                                    error: 'Upgrade to use more themes'
                                }
                            }
                        }
                    }
                }
            }
        }});

        await page.goto('/#/settings/theme');

        const themeSection = page.getByTestId('theme');

        await themeSection.getByRole('button', {name: 'Change theme'}).click();

        // Should show theme modal, not limit modal
        await expect(page.getByTestId('theme-modal')).toBeVisible();
        await expect(page.getByTestId('limit-modal')).not.toBeVisible();

        // Can interact with themes normally
        await expect(page.getByTestId('theme-modal').getByRole('button', {name: /Casper/})).toBeVisible();
    });

    test('Prevents direct access to design/change-theme route when limited', async ({page}) => {
        await mockApi({page, requests: {
            ...globalDataRequests,
            ...limitRequests,
            browseThemes: {method: 'GET', path: '/themes/', response: responseFixtures.themes},
            browseConfig: customThemesLimitConfig(['casper'], 'Upgrade to use custom themes')
        }});

        // Navigate directly to the change theme route
        await page.goto('/#/settings/design/change-theme');

        // Should show limit modal instead of theme modal
        // Wait for the limit modal to appear (async limit check needs time)
        await page.waitForSelector('[data-testid="limit-modal"]', {timeout: 10000});

        await expect(page.getByTestId('limit-modal')).toBeVisible();
        await expect(page.getByTestId('limit-modal')).toHaveText(/Upgrade to use custom themes/);

        // Theme modal should not be visible
        await expect(page.getByTestId('theme-modal')).not.toBeVisible();
    });

    test('Opens the editor from the active theme overflow menu and returns to settings on close', async ({page}) => {
        await mockApi({page, requests: {
            ...globalDataRequests,
            browseThemes: {method: 'GET', path: '/themes/', response: responseFixtures.themes},
            downloadTheme: themeDownloadRequest('edition')
        }});

        const editorModal = await openActiveThemeEditorFromSettings(page);

        await expect(editorModal).toBeVisible();
        await expect(page).toHaveURL(/#\/settings\/theme\/edit\/edition/);

        await editorModal.getByRole('button', {name: 'Close'}).click();

        await expect(page).toHaveURL(/#\/settings$/);
        await expect(page.getByTestId('theme')).toBeVisible();
    });

    test('Confirms before discarding unsaved theme edits on close', async ({page}) => {
        await mockApi({page, requests: {
            ...globalDataRequests,
            browseThemes: {method: 'GET', path: '/themes/', response: responseFixtures.themes},
            downloadTheme: themeDownloadRequest('edition')
        }});

        const editorModal = await openInstalledThemeEditor(page, 'edition');

        const codeEditor = editorModal.locator('.cm-content');
        await codeEditor.click();
        await page.keyboard.press('ControlOrMeta+A');
        await page.keyboard.insertText('{"name":"edition","version":"1.0.0"}\n');
        await expect(editorModal).toContainText('1 file modified');

        // Cancelling the discard keeps the editor open with the change intact
        await editorModal.getByRole('button', {name: 'Close'}).click();
        const confirmModal = page.getByTestId('theme-editor-confirm-modal');
        await expect(confirmModal).toContainText('You have unsaved theme changes');
        await confirmModal.getByRole('button', {name: 'Cancel'}).click();
        await expect(editorModal).toBeVisible();
        await expect(editorModal).toContainText('1 file modified');

        // Confirming the discard closes the editor
        await editorModal.getByRole('button', {name: 'Close'}).click();
        await expect(confirmModal).toContainText('You have unsaved theme changes');
        await confirmModal.getByRole('button', {name: 'Discard changes'}).click();
        await expect(page.getByTestId('theme-code-editor-modal')).not.toBeVisible();
    });

    test('Ignores from= values that point outside the editor return allowlist', async ({page}) => {
        await mockApi({page, requests: {
            ...globalDataRequests,
            browseThemes: {method: 'GET', path: '/themes/', response: responseFixtures.themes},
            downloadTheme: themeDownloadRequest('edition')
        }});

        // An attacker-crafted from= pointing at an unrelated admin route
        // should not be honoured. The editor should still load, but on close
        // we should land on the safe fallback (design/change-theme) rather
        // than the crafted destination.
        await page.goto('/#/settings/theme/edit/edition?from=staff/owner-transfer');

        const editorModal = page.getByTestId('theme-code-editor-modal');
        await expect(editorModal).toBeVisible();

        await editorModal.getByRole('button', {name: 'Close'}).click();

        await expect(page).not.toHaveURL(/staff\/owner-transfer/);
        await expect(page).toHaveURL(/#\/settings\/design\/change-theme/);
    });

    test('Honours legitimate from= values when closing the editor', async ({page}) => {
        await mockApi({page, requests: {
            ...globalDataRequests,
            browseThemes: {method: 'GET', path: '/themes/', response: responseFixtures.themes},
            downloadTheme: themeDownloadRequest('edition')
        }});

        // A from= value pointing at the theme settings page is in the
        // allowlist and should be honoured on close.
        await page.goto('/#/settings/theme/edit/edition?from=theme');

        const editorModal = page.getByTestId('theme-code-editor-modal');
        await expect(editorModal).toBeVisible();

        await editorModal.getByRole('button', {name: 'Close'}).click();

        await expect(page).toHaveURL(/#\/settings\/theme$/);
    });

    test('Prevents direct access to theme editor route when editing is limited', async ({page}) => {
        await mockApi({page, requests: {
            ...globalDataRequests,
            ...limitRequests,
            browseThemes: {method: 'GET', path: '/themes/', response: responseFixtures.themes},
            browseConfig: customThemesLimitConfig(['casper'], 'Upgrade to use custom themes')
        }});

        await page.goto('/#/settings/theme/edit/edition');

        await page.waitForSelector('[data-testid="limit-modal"]', {timeout: 10000});

        await expect(page.getByTestId('limit-modal')).toHaveText(/Upgrade to use custom themes/);
        await expect(page.getByTestId('theme-code-editor-modal')).not.toBeVisible();
    });

    test('Redirects malformed theme editor routes back to theme settings', async ({page}) => {
        await mockApi({page, requests: {
            ...globalDataRequests,
            browseThemes: {method: 'GET', path: '/themes/', response: responseFixtures.themes}
        }});

        const pageErrors: string[] = [];
        page.on('pageerror', error => pageErrors.push(error.message));

        await page.goto('/#/settings/theme/edit/%E0%A4%A');

        await expect(page).toHaveURL(/#\/settings\/theme$/);
        await expect(page.getByTestId('theme')).toBeVisible();
        await expect(page.getByTestId('theme-code-editor-modal')).not.toBeVisible();
        expect(pageErrors).toEqual([]);
    });

    test('Redirects encoded-slash theme editor routes back to theme settings', async ({page}) => {
        await mockApi({page, requests: {
            ...globalDataRequests,
            browseThemes: {method: 'GET', path: '/themes/', response: responseFixtures.themes}
        }});

        const pageErrors: string[] = [];
        page.on('pageerror', error => pageErrors.push(error.message));

        await page.goto('/#/settings/theme/edit/%2Fedition');

        await expect(page).toHaveURL(/#\/settings\/theme$/);
        await expect(page.getByTestId('theme')).toBeVisible();
        await expect(page.getByTestId('theme-code-editor-modal')).not.toBeVisible();
        expect(pageErrors).toEqual([]);
    });

    test('Allows selecting a non-editable file and shows the browser-edit message', async ({page}) => {
        await mockApi({page, requests: {
            ...globalDataRequests,
            browseThemes: {method: 'GET', path: '/themes/', response: responseFixtures.themes},
            downloadTheme: themeDownloadRequest('edition')
        }});

        const editorModal = await openInstalledThemeEditor(page, 'edition');

        await editorModal.getByRole('button', {name: '.DS_Store'}).click();

        await expect(editorModal).toContainText('This file cannot be edited in the browser.');
        await expect(editorModal.locator('.cm-editor')).toHaveCount(0);
    });

    test('Shows a controlled error when the downloaded theme archive exceeds editor limits', async ({page}) => {
        const oversizedThemeZip = await createArchiveBuffer((zip) => {
            for (let index = 0; index <= 1000; index += 1) {
                zip.file(`theme/partials/file-${index}.hbs`, `{{! file ${index} }}`);
            }
        });

        await mockApi({page, requests: {
            ...globalDataRequests,
            browseThemes: {method: 'GET', path: '/themes/', response: responseFixtures.themes},
            downloadTheme: {
                method: 'GET',
                path: '/themes/edition/download/',
                response: '',
                rawResponse: oversizedThemeZip,
                responseHeaders: {'content-type': 'application/zip'}
            }
        }});

        const editorModal = await openInstalledThemeEditor(page, 'edition');

        await expect(editorModal).toContainText('This theme archive contains too many files for the browser editor');
        await expect(editorModal.getByRole('button', {name: 'Save'})).toBeEnabled();
        await editorModal.getByRole('button', {name: 'Save'}).click();
        await expect(page.getByTestId('toast-info')).toHaveText(/No changes to save/i);
        await expect(editorModal).toContainText('Select a file from the tree to start editing.');
    });

    test('Theme install route works without limits', async ({page}) => {
        await mockApi({page, requests: {
            ...globalDataRequests,
            ...limitRequests,
            browseThemes: {method: 'GET', path: '/themes/', response: responseFixtures.themes},
            browseConfig: {
                ...globalDataRequests.browseConfig,
                response: {
                    config: {
                        ...responseFixtures.config.config,
                        hostSettings: {
                            limits: {
                                staff: {
                                    max: 10,
                                    error: 'You have reached the maximum number of staff users'
                                }
                                // No customThemes limit
                            }
                        }
                    }
                }
            },
            installTheme: {method: 'POST', path: /^\/themes\/install\/\?/, response: {
                themes: [{
                    name: 'taste',
                    package: {},
                    active: false,
                    templates: []
                }]
            }},
            activateTheme: {method: 'PUT', path: '/themes/taste/activate/', response: {
                themes: [{
                    name: 'taste',
                    package: {},
                    active: true,
                    templates: []
                }]
            }}
        }});

        await page.goto('/#/settings/theme/install?source=github&ref=TryGhost/Taste');

        await page.waitForSelector('[data-testid="theme-modal"]');

        const confirmation = page.getByTestId('confirmation-modal');

        await expect(confirmation).toHaveText(/Install Theme/);
        await expect(confirmation).toHaveText(/By clicking below, Taste will automatically be activated as the theme for your site/);

        // Can proceed with installation
        const installButton = confirmation.getByRole('button', {name: 'Install'});
        await expect(installButton).toBeVisible();
        await expect(installButton).toBeEnabled();

        await installButton.click();

        // Wait for the confirmation modal to close
        await expect(confirmation).not.toBeVisible();

        await expect(page.getByTestId('toast-success')).toHaveText(/taste is now your active theme/);
    });

    test('Theme install route allows installation when theme is in allowlist', async ({page}) => {
        await mockApi({page, requests: {
            ...globalDataRequests,
            ...limitRequests,
            browseThemes: {method: 'GET', path: '/themes/', response: responseFixtures.themes},
            browseConfig: {
                ...globalDataRequests.browseConfig,
                response: {
                    config: {
                        ...responseFixtures.config.config,
                        hostSettings: {
                            limits: {
                                customThemes: {
                                    allowlist: ['casper', 'headline', 'taste'],
                                    error: 'Upgrade to use more themes'
                                }
                            }
                        }
                    }
                }
            },
            installTheme: {method: 'POST', path: /^\/themes\/install\/\?/, response: {
                themes: [{
                    name: 'taste',
                    package: {},
                    active: false,
                    templates: []
                }]
            }},
            activateTheme: {method: 'PUT', path: '/themes/taste/activate/', response: {
                themes: [{
                    name: 'taste',
                    package: {},
                    active: true,
                    templates: []
                }]
            }}
        }});

        await page.goto('/#/settings/theme/install?source=github&ref=TryGhost/Taste');

        await page.waitForSelector('[data-testid="theme-modal"]');

        const confirmation = page.getByTestId('confirmation-modal');

        await expect(confirmation).toHaveText(/Install Theme/);
        await expect(confirmation).toHaveText(/By clicking below, Taste will automatically be activated as the theme for your site/);

        // Can proceed with installation because 'taste' is in the allowlist
        await confirmation.getByRole('button', {name: 'Install'}).click();
        await expect(page.getByTestId('toast-success')).toHaveText(/taste is now your active theme/);
    });

    test('Theme install route blocks installation, but shows change theme modal when theme is NOT in allowlist', async ({page}) => {
        await mockApi({page, requests: {
            ...globalDataRequests,
            ...limitRequests,
            browseThemes: {method: 'GET', path: '/themes/', response: responseFixtures.themes},
            browseConfig: {
                ...globalDataRequests.browseConfig,
                response: {
                    config: {
                        ...responseFixtures.config.config,
                        hostSettings: {
                            limits: {
                                customThemes: {
                                    allowlist: ['casper', 'headline', 'edition'], // 'taste' is NOT in the allowlist
                                    error: 'Upgrade to use more themes'
                                }
                            }
                        }
                    }
                }
            }
        }});

        await page.goto('/#/settings/theme/install?source=github&ref=TryGhost/Taste');

        // Should be redirected to /theme (not change-theme to avoid modal conflicts)
        await expect(page).toHaveURL(/#\/settings\/theme/);

        // Should show limit modal because 'taste' is not in the allowlist
        await expect(page.getByTestId('limit-modal')).toBeVisible();
        await expect(page.getByTestId('limit-modal')).toHaveText(/Upgrade to use more themes/);

        // Theme modal should NOT be visible
        await expect(page.getByTestId('theme-modal')).not.toBeVisible();
        await expect(page.getByTestId('confirmation-modal')).not.toBeVisible();

        // Close the limit modal
        await page.getByTestId('limit-modal').getByRole('button', {name: 'Cancel'}).click();

        // Should stay on /theme
        await expect(page).toHaveURL(/#\/settings\/theme/);
    });

    test('Theme install route blocks installation with single-theme allowlist', async ({page}) => {
        // When allowlist has only one theme, user shouldn't access theme modal at all
        await mockApi({page, requests: {
            ...globalDataRequests,
            ...limitRequests,
            browseThemes: {method: 'GET', path: '/themes/', response: responseFixtures.themes},
            browseConfig: {
                ...globalDataRequests.browseConfig,
                response: {
                    config: {
                        ...responseFixtures.config.config,
                        hostSettings: {
                            limits: {
                                customThemes: {
                                    allowlist: ['casper'], // Only one theme allowed
                                    error: 'Upgrade to use custom themes'
                                }
                            }
                        }
                    }
                }
            }
        }});

        await page.goto('/#/settings/theme/install?source=github&ref=TryGhost/Taste');

        // Should show limit modal immediately
        await expect(page.getByTestId('limit-modal')).toBeVisible();
        await expect(page.getByTestId('limit-modal')).toHaveText(/Upgrade to use custom themes/);

        // Should be redirected to /theme (not change-theme)
        await expect(page).toHaveURL(/#\/settings\/theme/);

        // Theme modal should not be visible at all
        await expect(page.getByTestId('theme-modal')).not.toBeVisible();
        await expect(page.getByTestId('confirmation-modal')).not.toBeVisible();
    });

    test('Prevents installing themes not in allowlist via UI', async ({page}) => {
        // Test the UI flow: clicking on a theme and trying to install when it's not in the allowlist
        await mockApi({page, requests: {
            ...globalDataRequests,
            ...limitRequests,
            browseThemes: {method: 'GET', path: '/themes/', response: responseFixtures.themes},
            browseConfig: {
                ...globalDataRequests.browseConfig,
                response: {
                    config: {
                        ...responseFixtures.config.config,
                        hostSettings: {
                            limits: {
                                customThemes: {
                                    allowlist: ['casper', 'edition'], // Headline is NOT in the allowlist
                                    error: 'Upgrade to use more themes'
                                }
                            }
                        }
                    }
                }
            }
        }});

        await page.goto('/#/settings/theme');

        const themeSection = page.getByTestId('theme');
        await themeSection.getByRole('button', {name: 'Change theme'}).click();

        const modal = page.getByTestId('theme-modal');

        // Try to install a theme that's not in the allowlist
        await modal.getByRole('button', {name: /Headline/}).click();

        // Clicking install should show the limit modal
        await modal.getByRole('button', {name: 'Install Headline'}).click();

        // Should show limit modal instead of installation confirmation
        await expect(page.getByTestId('limit-modal')).toBeVisible();
        await expect(page.getByTestId('limit-modal')).toHaveText(/Upgrade to use more themes/);

        // Installation confirmation should not be visible
        await expect(page.getByTestId('confirmation-modal')).not.toBeVisible();
    });

    test('Shows limit modal before overwrite confirmation for already installed themes', async ({page}) => {
        // Test case: User has Edition theme installed (from fixtures), tries to update it when it's not in the allowlist
        await mockApi({page, requests: {
            ...globalDataRequests,
            ...limitRequests,
            browseThemes: {method: 'GET', path: '/themes/', response: responseFixtures.themes},
            browseConfig: {
                ...globalDataRequests.browseConfig,
                response: {
                    config: {
                        ...responseFixtures.config.config,
                        hostSettings: {
                            limits: {
                                customThemes: {
                                    allowlist: ['casper', 'headline'], // Edition is NOT in the allowlist
                                    error: 'Upgrade to use more themes'
                                }
                            }
                        }
                    }
                }
            }
        }});

        await page.goto('/#/settings/theme');

        const themeSection = page.getByTestId('theme');
        await themeSection.getByRole('button', {name: 'Change theme'}).click();

        const modal = page.getByTestId('theme-modal');

        // Wait for modal to be ready
        await expect(modal).toBeVisible();

        await modal.getByRole('button', {name: /Edition/}).click();

        // Wait for the theme preview to load
        await expect(modal).toContainText('Edition');

        // Edition is already installed, so the button should say "Update Edition"
        // and would normally trigger the overwrite confirmation modal, but because
        // we have a limit now that doesn't allow this theme, it should show the limit modal instead.
        const installButton = modal.getByRole('button', {name: 'Update Edition'});
        await expect(installButton).toBeVisible();

        // Click the install/update button
        await installButton.click();

        // Should show limit modal FIRST, not the overwrite confirmation
        await expect(page.getByTestId('limit-modal')).toBeVisible();
        await expect(page.getByTestId('limit-modal')).toHaveText(/Upgrade to use more themes/);

        // Overwrite confirmation should NOT be visible
        await expect(page.getByTestId('confirmation-modal').filter({hasText: /overwrite/i})).not.toBeVisible();
    });
});
