import React from 'react';
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';

const state = vi.hoisted(() => ({
    request: vi.fn(), connection: 'backend', resolveServingId: vi.fn(), visibleApps: [] as any[], selectApp: vi.fn(),
    listeners: new Set<any>(), schemaCreate: vi.fn(), dashboardCreate: vi.fn(), schemas: vi.fn(), creationOutcome: undefined as unknown,
    appId: 'app-a', searchParams: new URLSearchParams(), read: vi.fn(), save: vi.fn(), refresh: vi.fn(), update: vi.fn(), create: vi.fn(), error: vi.fn(),
}));
vi.mock('react-router-dom', () => ({
    useParams: () => ({ appId: state.appId }), useNavigate: () => vi.fn(), useSearchParams: () => [state.searchParams],
}));
vi.mock('@nexuscrm/host-sdk', async original => ({
    ...await original<any>(),
    registerHostSessionLifecycleListener: (listener: any) => { state.listeners.add(listener); return () => state.listeners.delete(listener); },
    useApp: () => ({ currentFrontendAppId: 'crm', refreshApps: state.refresh, visibleApps: state.visibleApps, setCurrentAppId: state.selectApp }), generateUUID: () => 'save-key',
}));
vi.mock('@nexuscrm/salesforce-crm-standard-frontend/platform/components/ui', () => ({ useErrorToast: () => state.error }));
vi.mock('../src/platform/infrastructure/api/metadata', () => ({ metadataAPI: {
    getApplicationDefinition: async (id: string) => ({ id, label: id, frontendAppId: 'crm', navigationItems: [] }),
    getSchemas: state.schemas, createSchema: state.schemaCreate, createDashboard: state.dashboardCreate, getDashboards: async () => ({ dashboards: [] }), updateApp: state.update, createApp: state.create,
} }));
vi.mock('../src/platform/infrastructure/api/application-navigation', async importOriginal => ({
    ...await importOriginal<typeof import('../src/platform/infrastructure/api/application-navigation')>(),
    applicationNavigationAPI: { read: state.read, save: state.save },
}));
vi.mock('../src/platform/infrastructure/api/client', () => ({ api: {}, apiClient: { request: state.request, getSessionConnectionKind: () => state.connection } }));
vi.mock('../src/platform/infrastructure/api/salesforce-application', () => ({ salesforceApplicationAPI: { resolveServingId: state.resolveServingId } }));
vi.mock('../src/platform/features/studio/components/StudioSidebar', () => ({ StudioSidebar: (props: any) => <div>
    <span data-testid="items">{JSON.stringify(props.app.navigationItems)}</span>
    <button disabled={props.disabled} onClick={() => props.onReorderNavItems([])}>Remove all</button>
    <button disabled={props.disabled} onClick={() => props.onAddWebLink({ label: 'Docs', url: 'https://example.com/docs?q=x#section', icon: 'Book' })}>Add link</button>
    <button onClick={async () => { state.creationOutcome = await props.onAddObject({ label: 'Old session object', apiName: 'OldObject', icon: 'Box' }); }}>Create object</button>
    <button onClick={async () => { state.creationOutcome = await props.onAddDashboard({ label: 'Old session dashboard', icon: 'Box' }); }}>Create dashboard</button>
    <button onClick={props.onOpenSettings}>Settings</button>
    <button onClick={() => props.onSelectNavItem(props.app.navigationItems[0])}>Edit first item</button>
</div> }));
vi.mock('../src/platform/features/studio/components/StudioObjectEditor', () => ({ StudioObjectEditor: () => null }));
vi.mock('../src/platform/features/studio/components/StudioDashboardEditor', () => ({ StudioDashboardEditor: (props: any) => <div data-testid="dashboard-editor">{props.dashboardId}</div> }));
vi.mock('../src/platform/features/studio/components/StudioPermissions', () => ({ StudioPermissions: () => null }));
vi.mock('../src/platform/features/studio/components/AppSettingsEditor', () => ({ AppSettingsEditor: (props: any) => <button onClick={() => props.onChange({ label: 'Renamed', ...(props.isNewApp ? { id: 'created-app' } : {}) })}>Rename</button> }));
vi.mock('../src/platform/features/studio/dashboardConfigFactory', () => ({ buildNewDashboardConfig: () => ({ id: 'old-dashboard' }) }));
vi.mock('../src/platform/core/navigation/standardPageTabs', () => ({ getStandardPageTab: vi.fn(), standardPageTabKeys: () => [] }));
vi.mock('../src/platform/core/navigation/setupPagePaths', () => ({ tryGetSetupCapabilityPagePath: () => '/setup' }));
vi.mock('../src/platform/core/navigation/studioRoutes', () => ({ tryBuildStudioRoute: () => '/studio' }));
import { AppStudio } from '../src/platform/pages/AppStudio';

const original = { application_id: 'app-a', revision: 1, items: [{ id: 'hidden', label: 'Hidden admin item', icon: 'Book', target: { url: 'https://example.com/private?q=x#anchor' } }] };
const mount = () => render(<AppStudio dashboardWidgets={{} as any} />);
async function loaded() { await screen.findByText('Remove all'); }
function deferred<T>() { let resolve!: (value: T) => void; const promise = new Promise<T>(done => { resolve = done; }); return { promise, resolve }; }

beforeEach(() => {
    state.connection = 'backend'; state.resolveServingId.mockReset(); state.request.mockReset();
    state.visibleApps = [{ id: 'app-a', frontendAppId: 'crm', navigationItems: [{ type: 'object', objectApiName: 'Account' }] }];
    state.schemaCreate.mockReset().mockResolvedValue({});
    state.dashboardCreate.mockReset().mockResolvedValue({ dashboard: { id: 'old-dashboard' } });
    state.schemas.mockReset().mockResolvedValue({ schemas: [] });
    state.creationOutcome = undefined;
    vi.clearAllMocks(); state.searchParams = new URLSearchParams(); state.appId = 'app-a'; state.read.mockResolvedValue(structuredClone(original));
    state.save.mockImplementation(async (id, request) => ({ application_id: id, revision: 2, items: request.items }));
    state.refresh.mockResolvedValue(undefined); state.update.mockResolvedValue(undefined); state.create.mockResolvedValue({});
});
describe('App Studio current navigation', () => {
    it('launches the current visible serving ID after resolving an external editing ID', async () => {
        state.connection = 'external'; state.appId = '02u-app';
        const { salesforceApplicationAPI } = await vi.importActual<typeof import('../src/platform/infrastructure/api/salesforce-application')>('../src/platform/infrastructure/api/salesforce-application');
        state.resolveServingId.mockImplementation(salesforceApplicationAPI.resolveServingId);
        state.request.mockImplementation(async (path: string) => ({ records: decodeURIComponent(path).includes('FROM CustomApplication')
            ? [{ DeveloperName: 'Sales', NamespacePrefix: 'vendor' }]
            : [{ DurableId: '06m-app' }] }));
        state.visibleApps = [{ id: '06m-app:1', frontendAppId: 'crm', navigationItems: [{ type: 'object', objectApiName: 'Lead' }] }];
        const open = vi.spyOn(window, 'open').mockReturnValue(null);
        try {
            mount();
            await waitFor(() => expect(screen.getByRole('button', { name: 'Launch App' })).toBeEnabled());
            fireEvent.click(screen.getByRole('button', { name: 'Launch App' }));
            expect(state.resolveServingId).toHaveBeenCalledWith('02u-app');
            expect(state.selectApp).toHaveBeenCalledWith('06m-app:1');
            expect(open).toHaveBeenCalledWith('/object/Lead', '_blank', 'noopener,noreferrer');
            expect(state.read).not.toHaveBeenCalled();
        } finally { open.mockRestore(); }
    });
    it('does not launch an external application absent from the visible projection', async () => {
        state.connection = 'external'; state.appId = '02u-app';
        state.resolveServingId.mockResolvedValue('06m-inaccessible');
        mount();
        await waitFor(() => expect(state.resolveServingId).toHaveBeenCalled());
        expect(screen.getByRole('button', { name: 'Launch App' })).toBeDisabled();
    });
    it('discards an external identity resolution completed after session clear', async () => {
        state.connection = 'external'; state.appId = '02u-app';
        const pending = deferred<string>(); state.resolveServingId.mockReturnValue(pending.promise);
        state.visibleApps = [{ id: '06m-app', frontendAppId: 'crm', navigationItems: [{ type: 'object', objectApiName: 'Lead' }] }];
        mount();
        await waitFor(() => expect(state.resolveServingId).toHaveBeenCalled());
        await act(async () => { for (const listener of state.listeners) listener.onCleared?.(); pending.resolve('06m-app'); });
        expect(screen.queryByRole('button', { name: 'Launch App' })).not.toBeInTheDocument();
    });

    it('disables launch when saved navigation contains an unsafe web URL', async () => {
        state.visibleApps = [{ id: 'app-a', frontendAppId: 'crm', navigationItems: [{ type: 'web', pageUrl: 'javascript:alert(1)' }] }];
        const open = vi.spyOn(window, 'open').mockReturnValue(null);
        try {
            mount(); await loaded();
            const launch = screen.getByRole('button', { name: 'Launch App' });
            expect(launch).toBeDisabled();
            fireEvent.click(launch);
            expect(open).not.toHaveBeenCalled();
        } finally { open.mockRestore(); }
    });
    it('opens a saved web entry as a document URL, preserving its query and fragment', async () => {
        const href = 'https://example.test/docs?q=hello%20world#section';
        state.visibleApps = [{ id: 'app-a', frontendAppId: 'crm', navigationItems: [{ type: 'web', pageUrl: href }] }];
        const open = vi.spyOn(window, 'open').mockReturnValue(null);
        try {
            mount(); await loaded();
            fireEvent.click(screen.getByRole('button', { name: 'Launch App' }));
            expect(open).toHaveBeenCalledWith(href, '_blank', 'noopener,noreferrer');
        } finally { open.mockRestore(); }
    });
    it('launches the saved visible application after Home is hidden, ignoring the editor draft', async () => {
        const open = vi.spyOn(window, 'open').mockReturnValue(null);
        try {
            mount(); await loaded();
            fireEvent.click(screen.getByText('Remove all'));
            fireEvent.click(screen.getByRole('button', { name: 'Launch App' }));
            expect(state.selectApp).toHaveBeenCalledWith('app-a');
            expect(open).toHaveBeenCalledWith('/object/Account', '_blank', 'noopener,noreferrer');
            expect(state.save).not.toHaveBeenCalled();
        } finally { open.mockRestore(); }
    });
    it.each([{ apps: [] }, { apps: [{ id: 'other', navigationItems: [{ type: 'object', objectApiName: 'Account' }] }] }, { apps: [{ id: 'app-a', navigationItems: [] }] }])('does not launch an unavailable target from its authoring document: $apps', async ({ apps }) => {
        state.visibleApps = apps;
        mount(); await loaded();
        expect(screen.getByRole('button', { name: 'Launch App' })).toBeDisabled();
    });
    it('does not offer unsupported installed application creation', async () => {
        state.appId = 'new'; mount();
        expect(await screen.findByText('Applications are supplied by the installed application release.')).toBeInTheDocument();
        expect(state.create).not.toHaveBeenCalled();
        expect(state.save).not.toHaveBeenCalled();
    });

    it('keeps an unavailable page editable and saves its stable identity without a route', async () => {
        state.read.mockResolvedValue({ ...original, items: [{ id: 'retired', label: 'Old page', target: { page: { id: 'RetiredPage' } } }] });
        mount(); await loaded(); fireEvent.click(screen.getByText('Edit first item'));
        expect(screen.getByRole('status')).toHaveTextContent('This page is unavailable');
        expect(screen.getByLabelText('Page')).toHaveValue('RetiredPage');
        fireEvent.change(screen.getByLabelText('Label'), { target: { value: 'Keep for later' } });
        fireEvent.click(screen.getByText('Save navigation'));
        await waitFor(() => expect(state.refresh).toHaveBeenCalledTimes(1));
        expect(state.save.mock.calls[0][1].items).toEqual([{ id: 'retired', label: 'Keep for later', target: { page: { id: 'RetiredPage' } } }]);
    });
    it('closes the editor when Cancel removes its newly added item', async () => {
        mount(); await loaded(); fireEvent.click(screen.getByText('Add link'));
        expect(screen.getByLabelText('Label')).toHaveValue('Docs');
        fireEvent.click(screen.getByText('Cancel navigation changes'));
        expect(screen.queryByLabelText('Label')).not.toBeInTheDocument();
        expect(screen.getByText('Welcome to App Studio')).toBeInTheDocument();
        expect(screen.getByTestId('items')).not.toHaveTextContent('Docs');
        expect(state.save).not.toHaveBeenCalled();
    });
    it('loads the complete admin document and only persists explicit Save, including an empty document', async () => {
        mount(); await loaded(); expect(screen.getByTestId('items')).toHaveTextContent('Hidden admin item');
        fireEvent.click(screen.getByText('Remove all')); expect(state.save).not.toHaveBeenCalled(); expect(state.update).not.toHaveBeenCalled();
        fireEvent.click(screen.getByText('Cancel navigation changes')); expect(screen.getByTestId('items')).toHaveTextContent('Hidden admin item');
        fireEvent.click(screen.getByText('Remove all')); fireEvent.click(screen.getByText('Save navigation'));
        await waitFor(() => expect(state.refresh).toHaveBeenCalledWith({ throwOnError: true, notifyOtherTabs: true }));
        expect(state.save).toHaveBeenCalledWith('app-a', { expected_revision: 1, idempotency_key: 'save-key', items: [] });
        expect(screen.getByTestId('items')).toHaveTextContent('[]');
    });
    it('keeps an uncertain request frozen and retries its exact key and payload', async () => {
        state.save.mockRejectedValueOnce(new Error('Connection lost'));
        mount(); await loaded(); fireEvent.click(screen.getByText('Add link')); fireEvent.click(screen.getByText('Save navigation'));
        await screen.findByText('Retry navigation save');
        expect(screen.getByText('Add link')).toBeDisabled();
        fireEvent.click(screen.getByText('Retry navigation save'));
        await waitFor(() => expect(state.refresh).toHaveBeenCalledTimes(1));
        expect(state.save.mock.calls[1]).toEqual(state.save.mock.calls[0]);
        expect(state.save.mock.calls[0][1].items).toHaveLength(2);
    });
    it('retries a failed menu refresh without repeating a committed save', async () => {
        state.refresh.mockRejectedValueOnce(new Error('Offline'));
        mount(); await loaded(); fireEvent.click(screen.getByText('Remove all')); fireEvent.click(screen.getByText('Save navigation'));
        fireEvent.click(await screen.findByText('Retry menu refresh'));
        await waitFor(() => expect(state.refresh).toHaveBeenCalledTimes(2));
        expect(state.save).toHaveBeenCalledTimes(1);
    });
    it('preserves a conflicting draft until the user explicitly loads the latest revision', async () => {
        state.save.mockRejectedValueOnce(Object.assign(new Error('Changed elsewhere'), { status: 409 }));
        mount(); await loaded(); fireEvent.click(screen.getByText('Remove all')); fireEvent.click(screen.getByText('Save navigation'));
        const reload = await screen.findByText('Load latest navigation');
        expect(screen.getByTestId('items')).toHaveTextContent('[]');
        expect(screen.getByText('Save navigation')).toBeDisabled();
        state.read.mockResolvedValue({ ...original, revision: 4, items: [{ ...original.items[0], label: 'Latest link' }] });
        fireEvent.click(reload);
        await waitFor(() => expect(screen.getByTestId('items')).toHaveTextContent('Latest link'));
        fireEvent.click(screen.getByText('Remove all')); fireEvent.click(screen.getByText('Save navigation'));
        await waitFor(() => expect(state.save).toHaveBeenCalledTimes(2));
        expect(state.save.mock.calls[1][1].expected_revision).toBe(4);
    });
    it('does not send a navigation draft through app settings', async () => {
        mount(); await loaded(); fireEvent.click(screen.getByText('Remove all')); fireEvent.click(screen.getByText('Settings')); fireEvent.click(screen.getByText('Rename'));
        expect(screen.queryByText('Save settings')).not.toBeInTheDocument();
        expect(state.update).not.toHaveBeenCalled(); expect(state.save).not.toHaveBeenCalled();
        expect(screen.getByText('Save navigation')).not.toBeDisabled();
    });
    it('does not apply a late save response to another app', async () => {
        const pending = deferred<typeof original>(); state.save.mockReturnValueOnce(pending.promise);
        const view = mount(); await loaded(); fireEvent.click(screen.getByText('Remove all')); fireEvent.click(screen.getByText('Save navigation'));
        state.appId = 'app-b'; state.read.mockResolvedValue({ application_id: 'app-b', revision: 3, items: [{ ...original.items[0], label: 'App B link' }] });
        view.rerender(<AppStudio dashboardWidgets={{} as any} />);
        await screen.findByText('app-b'); await loaded(); pending.resolve({ ...original, revision: 2, items: [] });
        await waitFor(() => expect(screen.getByTestId('items')).toHaveTextContent('App B link'));
        expect(state.refresh).not.toHaveBeenCalled();
    });
});

function transition(event: 'clear' | 'replace') {
    act(() => { for (const listener of [...state.listeners]) {
        if (event === 'clear') listener.onCleared?.();
        else listener.onAuthenticated?.({ userId: 'actor-b' });
    } });
}
it.each(['clear', 'replace'] as const)('discards a delayed navigation read after session %s', async event => {
    const pending = deferred<typeof original>(); state.read.mockReturnValueOnce(pending.promise);
    mount(); await waitFor(() => expect(state.read).toHaveBeenCalledTimes(1));
    state.read.mockResolvedValue({ ...original, revision: 9, items: [{ ...original.items[0], label: 'Actor B item' }] });
    transition(event);
    await act(async () => { pending.resolve(original); });
    expect(screen.queryByText('Hidden admin item')).not.toBeInTheDocument();
    if (event === 'replace') await waitFor(() => expect(screen.getByTestId('items')).toHaveTextContent('Actor B item'));
    else expect(state.read).toHaveBeenCalledTimes(1);
});
it.each(['clear', 'replace'] as const)('clears an uncertain save across session %s and never replays its payload', async event => {
    state.save.mockRejectedValueOnce(new Error('Connection lost after request'));
    mount(); await loaded(); fireEvent.click(screen.getByText('Remove all')); fireEvent.click(screen.getByText('Save navigation'));
    await screen.findByText('Retry navigation save');
    state.read.mockResolvedValue({ ...original, revision: 9, items: [{ ...original.items[0], label: 'Actor B item' }] });
    transition(event);
    if (event === 'clear') transition('replace');
    await waitFor(() => expect(screen.getByTestId('items')).toHaveTextContent('Actor B item'));
    expect(screen.queryByText('Retry navigation save')).not.toBeInTheDocument();
    fireEvent.click(screen.getByText('Add link')); fireEvent.click(screen.getByText('Save navigation'));
    await waitFor(() => expect(state.save).toHaveBeenCalledTimes(2));
    expect(state.save.mock.calls[1][1]).toMatchObject({ expected_revision: 9 });
    expect(state.save.mock.calls[1][1].items).toHaveLength(2);
});
it.each(['clear', 'replace'] as const)('does not publish a late save success after session %s', async event => {
    const pending = deferred<typeof original>(); state.save.mockReturnValueOnce(pending.promise);
    mount(); await loaded(); fireEvent.click(screen.getByText('Remove all')); fireEvent.click(screen.getByText('Save navigation'));
    state.read.mockResolvedValue({ ...original, revision: 9, items: [{ ...original.items[0], label: 'Actor B item' }] });
    transition(event);
    await act(async () => { pending.resolve({ ...original, revision: 2, items: [] }); });
    expect(state.refresh).not.toHaveBeenCalled();
    if (event === 'replace') await waitFor(() => expect(screen.getByTestId('items')).toHaveTextContent('Actor B item'));
    expect(screen.queryByText('Retry navigation save')).not.toBeInTheDocument();
});

it.each((['clear', 'replace'] as const).flatMap(event => (['object', 'dashboard'] as const).flatMap(action => (['create', 'choices'] as const).map(boundary => ({ event, action, boundary })))))(
    'discards $action creation continuation at $boundary after session $event', async ({ event, action, boundary }) => {
        const pending = deferred<any>();
        mount(); await loaded();
        const create = action === 'object' ? state.schemaCreate : state.dashboardCreate;
        if (boundary === 'create') create.mockReturnValueOnce(pending.promise);
        else state.schemas.mockReturnValueOnce(pending.promise);
        fireEvent.click(screen.getByText(`Create ${action}`));
        await waitFor(() => expect(boundary === 'create' ? create : state.schemas).toHaveBeenCalledTimes(boundary === 'create' ? 1 : 2));
        state.read.mockResolvedValue({ ...original, revision: 9, items: [{ ...original.items[0], label: 'Actor B item' }] });
        transition(event);
        await act(async () => { pending.resolve(boundary === 'choices' ? { schemas: [] } : { dashboard: { id: 'old-dashboard' } }); });
        await waitFor(() => expect(state.creationOutcome).toBe(false));
        if (event === 'replace') {
            expect(screen.getByTestId('items')).toHaveTextContent('Actor B item');
            expect(screen.getByTestId('items')).not.toHaveTextContent('Old session');
        }
    },
);

it('honors an initial dashboard deep link after application initialization and session reload', async () => {
    state.searchParams = new URLSearchParams('dashboardId=linked-dashboard');
    mount(); await loaded();
    expect(screen.getByTestId('dashboard-editor')).toHaveTextContent('linked-dashboard');
    transition('replace'); await loaded();
    expect(screen.getByTestId('dashboard-editor')).toHaveTextContent('linked-dashboard');
});
it('updates dashboard deep links without discarding a navigation draft', async () => {
    const view = mount(); await loaded(); fireEvent.click(screen.getByText('Add link'));
    state.searchParams = new URLSearchParams('dashboardId=next-dashboard');
    view.rerender(<AppStudio dashboardWidgets={{} as any} />);
    expect(screen.getByTestId('dashboard-editor')).toHaveTextContent('next-dashboard');
    expect(screen.getByTestId('items')).toHaveTextContent('Docs');
    expect(screen.getByText('Save navigation')).not.toBeDisabled();
});


it.each((['clear', 'replace'] as const).flatMap(event => (['save', 'object', 'dashboard'] as const).map(action => ({ event, action }))))(
    'rejects the old $action callback inside the session $event notification batch', async ({ event, action }) => {
        mount(); await loaded(); fireEvent.click(screen.getByText('Remove all'));
        const button = screen.getByText(action === 'save' ? 'Save navigation' : action === 'object' ? 'Create object' : 'Create dashboard');
        await act(async () => {
            // Keep the React commit pending while notifying and invoking the
            // still-mounted previous-session control in the same event batch.
            for (const listener of state.listeners) {
                if (event === 'clear') listener.onCleared?.();
                else listener.onAuthenticated?.({ userId: 'actor-b' });
            }
            fireEvent.click(button);
        });
        expect(state.save).not.toHaveBeenCalled();
        expect(state.schemaCreate).not.toHaveBeenCalled();
        expect(state.dashboardCreate).not.toHaveBeenCalled();
    },
);
