// (C) Copyright 2015 Moodle Pty Ltd.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
//     http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

import { TestingBehatDomUtils, TestingBehatDomUtilsService } from './behat-dom';
import { TestingBehatBlocking } from './behat-blocking';
import { CoreCustomURLSchemes, CoreCustomURLSchemesProvider } from '@services/urlschemes';
import { ONBOARDING_DONE } from '@features/login/constants';
import { CoreConfig } from '@services/config';
import { EnvironmentConfig } from '@/types/config';
import { LocalNotifications, makeSingleton, NgZone, ToastController } from '@singletons';
import { CoreNetwork, CoreNetworkService } from '@services/network';
import { CorePushNotifications, CorePushNotificationsProvider } from '@features/pushnotifications/services/pushnotifications';
import { CoreCronDelegate, CoreCronDelegateService } from '@services/cron';
import { CoreLoadingComponent } from '@components/loading/loading';
import { CoreDirectivesRegistry } from '@singletons/directives-registry';
import { CoreDom } from '@singletons/dom';
import { Injectable } from '@angular/core';
import { CoreSites, CoreSitesProvider } from '@services/sites';
import { CoreNavigator, CoreNavigatorService } from '@services/navigator';
import { CoreSwipeNavigationDirective } from '@directives/swipe-navigation';
import { Swiper } from 'swiper';
import { LocalNotificationsMock } from '@features/emulator/services/local-notifications';
import { GetClosureArgs } from '@/core/utils/types';
import { CoreIframeComponent } from '@components/iframe/iframe';
import { CoreUtils } from '@services/utils/utils';

/**
 * Behat runtime servive with public API.
 */
@Injectable({ providedIn: 'root' })
export class TestingBehatRuntimeService {

    protected initialized = false;
    protected openedUrls: {
        args: GetClosureArgs<Window['open']>;
        contents?: string;
    }[] = [];

    get cronDelegate(): CoreCronDelegateService {
        return CoreCronDelegate.instance;
    }

    get customUrlSchemes(): CoreCustomURLSchemesProvider {
        return CoreCustomURLSchemes.instance;
    }

    get network(): CoreNetworkService {
        return CoreNetwork.instance;
    }

    get pushNotifications(): CorePushNotificationsProvider {
        return CorePushNotifications.instance;
    }

    get sites(): CoreSitesProvider {
        return CoreSites.instance;
    }

    get navigator(): CoreNavigatorService {
        return CoreNavigator.instance;
    }

    get domUtils(): TestingBehatDomUtilsService {
        return TestingBehatDomUtils.instance;
    }

    /**
     * Init behat functions and set options like skipping onboarding.
     *
     * @param options Options to set on the app.
     */
    init(options: TestingBehatInitOptions = {}): void {
        if (this.initialized) {
            return;
        }

        this.initialized = true;
        TestingBehatBlocking.init();

        if (options.skipOnBoarding) {
            CoreConfig.set(ONBOARDING_DONE, 1);
        }

        if (options.configOverrides) {
            // Set the cookie so it's maintained between reloads.
            document.cookie = 'MoodleAppConfig=' + JSON.stringify(options.configOverrides);
            CoreConfig.patchEnvironment(options.configOverrides, { patchDefault: true });
        }

        // Spy on window.open.
        const originalOpen = window.open.bind(window);
        window.open = (...args) => {
            this.openedUrls.push({ args });

            return originalOpen(...args);
        };

        // Reduce iframes timeout to speed up tests.
        CoreIframeComponent.loadingTimeout = 1000;
    }

    /**
     * Check whether the service has been initialized or not.
     *
     * @returns Whether the service has been initialized or not.
     */
    hasInitialized(): boolean {
        return this.initialized;
    }

    /**
     * Run an operation inside the angular zone and return result.
     *
     * @param operation Operation callback.
     * @returns OK if successful, or ERROR: followed by message.
     */
    async runInZone(operation: () => unknown, blocking: boolean = false): Promise<string> {
        const blockKey = blocking && TestingBehatBlocking.block();

        try {
            await NgZone.run(operation);

            return 'OK';
        } catch (error) {
            return 'ERROR: ' + error.message;
        } finally {
            blockKey && TestingBehatBlocking.unblock(blockKey);
        }
    }

    /**
     * Wait all controlled components to be rendered.
     *
     * @returns Promise resolved when all components have been rendered.
     */
    async waitLoadingToFinish(): Promise<void> {
        await NgZone.run(async () => {
            const elements = Array.from(document.body.querySelectorAll<HTMLElement>('core-loading'))
                .filter((element) => CoreDom.isElementVisible(element));

            await Promise.all(elements.map(element =>
                CoreDirectivesRegistry.waitDirectiveReady(element, CoreLoadingComponent)));
        });
    }

    /**
     * Function to find and click an app standard button.
     *
     * @param button Type of button to press.
     * @returns OK if successful, or ERROR: followed by message.
     */
    async pressStandard(button: string): Promise<string> {
        this.log('Action - Click standard button: ' + button);

        // Find button
        let foundButton: HTMLElement | undefined;
        const options: TestingBehatFindOptions = {
            onlyClickable: true,
            containerName: '',
        };

        switch (button) {
            case 'back':
                foundButton = TestingBehatDomUtils.findElementBasedOnText({ text: 'Back' }, options);
                break;
            case 'main menu': // Deprecated name.
            case 'more menu':
                foundButton = TestingBehatDomUtils.findElementBasedOnText({
                    text: 'More',
                    selector: 'ion-tab-button',
                }, options);
                break;
            case 'user menu' :
                foundButton = TestingBehatDomUtils.findElementBasedOnText({ text: 'User account' }, options);
                break;
            case 'page menu':
                foundButton = TestingBehatDomUtils.findElementBasedOnText({ text: 'Display options' }, options);
                break;
            default:
                return 'ERROR: Unsupported standard button type';
        }

        if (!foundButton) {
            return `ERROR: Button '${button}' not found`;
        }

        // Click button
        await TestingBehatDomUtils.pressElement(foundButton);

        return 'OK';
    }

    /**
     * When there is a popup, clicks on the backdrop.
     *
     * @returns OK if successful, or ERROR: followed by message
     */
    closePopup(): string {
        this.log('Action - Close popup');

        const backdrops = [
            ...Array
                .from(document.querySelectorAll('ion-popover, ion-modal'))
                .map(popover => popover.shadowRoot?.querySelector('ion-backdrop'))
                .filter(backdrop => !!backdrop),
            ...Array
                .from(document.querySelectorAll('ion-backdrop'))
                .filter(backdrop => !!backdrop.offsetParent),
        ];

        if (!backdrops.length) {
            return 'ERROR: Could not find backdrop';
        }

        if (backdrops.length > 1) {
            return 'ERROR: Found too many backdrops ('+backdrops.length+')';
        }

        backdrops[0]?.click();

        // Mark busy until the click finishes processing.
        TestingBehatBlocking.delay();

        return 'OK';
    }

    /**
     * Function to find an arbitrary element based on its text or aria label.
     *
     * @param locator Element locator.
     * @param options Search options.
     * @returns OK if successful, or ERROR: followed by message
     */
    find(locator: TestingBehatElementLocator, options: Partial<TestingBehatFindOptions> = {}): string {
        this.log('Action - Find', { locator, ...options });

        try {
            const element = TestingBehatDomUtils.findElementBasedOnText(locator, {
                onlyClickable: false,
                containerName: '',
                ...options,
            });

            if (!element) {
                return 'ERROR: No element matches locator to find.';
            }

            this.log('Action - Found', { locator, element, ...options });

            return 'OK';
        } catch (error) {
            return 'ERROR: ' + error.message;
        }
    }

    /**
     * Scroll an element into view.
     *
     * @param locator Element locator.
     * @returns OK if successful, or ERROR: followed by message
     */
    scrollTo(locator: TestingBehatElementLocator): string {
        this.log('Action - scrollTo', { locator });

        try {
            let element = TestingBehatDomUtils.findElementBasedOnText(locator, { onlyClickable: false, containerName: '' });

            if (!element) {
                return 'ERROR: No element matches element to scroll to.';
            }

            element = element.closest('ion-item') ?? element.closest('button') ?? element;

            element.scrollIntoView();

            this.log('Action - Scrolled to', { locator, element });

            return 'OK';
        } catch (error) {
            return 'ERROR: ' + error.message;
        }
    }

    /**
     * Check whether the given url has been opened in the app.
     *
     * @param urlPattern Url pattern.
     * @param contents Url contents.
     * @param times How many times it should have been opened.
     * @returns OK if successful, or ERROR: followed by message
     */
    async hasOpenedUrl(urlPattern: string, contents: string, times: number): Promise<string> {
        const urlRegExp = new RegExp(urlPattern);
        const urlMatches = await Promise.all(this.openedUrls.map(async (openedUrl) => {
            const renderedUrl = openedUrl.args[0]?.toString() ?? '';

            if (!urlRegExp.test(renderedUrl)) {
                return false;
            }

            if (contents && !('contents' in openedUrl)) {
                const response = await fetch(renderedUrl);

                openedUrl.contents = await response.text();
            }

            if (contents && contents !== openedUrl.contents) {
                return false;
            }

            return true;
        }));

        if (urlMatches.filter(matches => !!matches).length === times) {
            return 'OK';
        }

        return times === 1
            ? `ERROR: Url matching '${urlPattern}' with '${contents}' contents has not been opened once`
            : `ERROR: Url matching '${urlPattern}' with '${contents}' contents has not been opened ${times} times`;
    }

    /**
     * Load more items form an active list with infinite loader.
     *
     * @returns OK if successful, or ERROR: followed by message
     */
    async loadMoreItems(): Promise<string> {
        this.log('Action - loadMoreItems');

        try {
            const infiniteLoading = Array
                .from(document.querySelectorAll<HTMLElement>('core-infinite-loading'))
                .find(element => !element.closest('.ion-page-hidden'));

            if (!infiniteLoading) {
                return 'ERROR: There isn\'t an infinite loader in the current page.';
            }

            const initialOffset = infiniteLoading.offsetTop;
            const isLoading = () => !!infiniteLoading.querySelector('ion-spinner[aria-label]');
            const isCompleted = () => !isLoading() && !infiniteLoading.querySelector('ion-button');
            const hasMoved = () => infiniteLoading.offsetTop !== initialOffset;

            if (isCompleted()) {
                return 'ERROR: All items are already loaded.';
            }

            infiniteLoading.scrollIntoView({ behavior: 'smooth' });

            // Wait 100ms
            await new Promise(resolve => setTimeout(resolve, 100));

            if (isLoading() || isCompleted() || hasMoved()) {
                return 'OK';
            }

            infiniteLoading.querySelector<HTMLElement>('ion-button')?.click();

            // Wait 100ms
            await new Promise(resolve => setTimeout(resolve, 100));

            return (isLoading() || isCompleted() || hasMoved()) ? 'OK' : 'ERROR: Couldn\'t load more items.';
        } catch (error) {
            return 'ERROR: ' + error.message;
        }
    }

    /**
     * Check whether an item is selected or not.
     *
     * @param locator Element locator.
     * @returns YES or NO if successful, or ERROR: followed by message
     */
    isSelected(locator: TestingBehatElementLocator): string {
        this.log('Action - Is Selected', locator);

        try {
            const element = TestingBehatDomUtils.findElementBasedOnText(locator, { onlyClickable: false, containerName: '' });

            if (!element) {
                return 'ERROR: No element matches locator to find.';
            }

            return TestingBehatDomUtils.isElementSelected(element) ? 'YES' : 'NO';
        } catch (error) {
            return 'ERROR: ' + error.message;
        }
    }

    /**
     * Function to press arbitrary item based on its text or Aria label.
     *
     * @param locator Element locator.
     * @returns OK if successful, or ERROR: followed by message
     */
    async press(locator: TestingBehatElementLocator): Promise<string>;
    async press(text: string, nearText?: string): Promise<string>;
    async press(locatorOrText: TestingBehatElementLocator | string, nearText?: string): Promise<string> {
        const locator = typeof locatorOrText === 'string' ? { text: locatorOrText } : locatorOrText;

        if (nearText) {
            locator.near = { text: nearText };
        }

        this.log('Action - Press', locator);

        try {
            const found = TestingBehatDomUtils.findElementBasedOnText(locator, { onlyClickable: true, containerName: '' });

            if (!found) {
                return 'ERROR: No element matches locator to press.';
            }

            await TestingBehatDomUtils.pressElement(found);

            return 'OK';
        } catch (error) {
            return 'ERROR: ' + error.message;
        }
    }

    /**
     * Get a file input id, adding it if necessary.
     *
     * @param locator Input locator.
     * @returns Input id if successful, or ERROR: followed by message
     */
    async getFileInputId(locator: TestingBehatElementLocator): Promise<string> {
        this.log('Action - Upload File', { locator });

        try {
            const inputOrContainer = TestingBehatDomUtils.findElementBasedOnText(locator);

            if (!inputOrContainer) {
                return 'ERROR: No element matches input locator.';
            }

            const input = inputOrContainer.matches('input[type="file"]')
                ? inputOrContainer
                : inputOrContainer.querySelector('input[type="file"]');

            if (!input) {
                return 'ERROR: Input element does not contain a file input.';
            }

            if (!input.hasAttribute('id')) {
                input.setAttribute('id', `file-${Date.now()}`);
            }

            return input.getAttribute('id') ?? '';
        } catch (error) {
            return 'ERROR: ' + error.message;
        }
    }

    /**
     * Trigger a pull to refresh gesture in the current page.
     *
     * @returns OK if successful, or ERROR: followed by message
     */
    async pullToRefresh(): Promise<string> {
        this.log('Action - pullToRefresh');

        try {
            const ionRefresher = this.getElement('ion-refresher');

            if (!ionRefresher) {
                return 'ERROR: It\'s not possible to pull to refresh the current page.';
            }

            ionRefresher.dispatchEvent(new CustomEvent('ionRefresh'));

            return 'OK';
        } catch (error) {
            return 'ERROR: ' + error.message;
        }
    }

    /**
     * Gets the currently displayed page header.
     *
     * @returns OK: followed by header text if successful, or ERROR: followed by message.
     */
    getHeader(): string {
        this.log('Action - Get header');

        let titles = Array.from(document.querySelectorAll<HTMLElement>('.ion-page:not(.ion-page-hidden) > ion-header h1'))
            .filter((title) => TestingBehatDomUtils.isElementVisible(title, document.body))
            .map((title) => title.innerText.trim());

        // Collapsed title, get the floating title.
        if (titles.length < 0 || (titles.length === 1 && titles[0] === '')) {
            titles = Array.from(document.querySelectorAll<HTMLElement>(
                '.ion-page:not(.ion-page-hidden) h1.collapsible-header-floating-title',
                )).filter((title) => TestingBehatDomUtils.isElementVisible(title, document.body))
                .map((title) => title.innerText.trim());
        }

        if (titles.length > 1) {
            return `ERROR: Too many possible titles (${titles.length}).`;
        }

        if (!titles.length) {
            return 'ERROR: No title found.';
        }

        return `OK: ${titles[0]}`;
    }

    /**
     * Sets the text of a field to the specified value.
     *
     * This currently matches fields only based on the placeholder attribute.
     *
     * @param field Field name
     * @param value New value
     * @returns OK or ERROR: followed by message
     */
    async setField(field: string, value: string): Promise<string> {
        this.log('Action - Set field ' + field + ' to: ' + value);

        const input = TestingBehatDomUtils.findField(field);

        if (!input) {
            return 'ERROR: No element matches field to set.';
        }

        if (input instanceof HTMLSelectElement) {
            const options = Array.from(input.querySelectorAll('option'));

            value = options.find(option => option.value === value)?.value
                ?? options.find(option => option.text === value)?.value
                ?? options.find(option => option.text.includes(value))?.value
                ?? value;
        } else if (input.tagName === 'ION-SELECT') {
            const options = Array.from(input.querySelectorAll('ion-select-option'));

            value = options.find(option => option.value?.toString() === value)?.textContent?.trim()
                ?? options.find(option => option.textContent?.trim() === value)?.textContent?.trim()
                ?? options.find(option => option.textContent?.includes(value))?.textContent?.trim()
                ?? value;
        }

        try {
            await TestingBehatDomUtils.setInputValue(input, value);

            return 'OK';
        } catch (error) {
            return `ERROR: ${error.message ?? 'Unknown error'}`;
        }
    }

    /**
     * Sets the text of a field to the specified value.
     *
     * This currently matches fields only based on the placeholder attribute.
     *
     * @param field Field name
     * @param value New value
     * @returns OK or ERROR: followed by message
     */
    async fieldMatches(field: string, value: string): Promise<string> {
        this.log('Action - Field ' + field + ' matches value: ' + value);

        const found = TestingBehatDomUtils.findField(field);

        if (!found) {
            return 'ERROR: No element matches field to set.';
        }

        const foundValue = this.getFieldValue(found);
        if (value !== foundValue) {
            return `ERROR: Expecting value "${value}", found "${foundValue}" instead.`;
        }

        return 'OK';
    }

    /**
     * Get the value of a certain field.
     *
     * @param element Field to get the value.
     * @returns Value.
     */
    protected getFieldValue(element: HTMLElement | HTMLInputElement): string {
        if (element.tagName === 'ION-DATETIME-BUTTON' && element.shadowRoot) {
            return Array.from(element.shadowRoot.querySelectorAll('button')).map(button => button.innerText).join(' ');
        }

        if (element.tagName === 'ION-DATETIME') {
            const value = 'value' in element ? element.value : element.innerText;

            // Remove seconds from the value to ensure stability on tests. It could be improved using moment parsing if needed.
            return value.substring(0, value.length - 3);
        }

        return 'value' in element ? element.value : element.innerText;
    }

    /**
     * Get element instance.
     *
     * @param selector Element selector.
     * @param referenceLocator The locator to the reference element to start looking for. If not specified, document body.
     * @returns Element instance.
     */
    private getElement<T = Element>(selector: string, referenceLocator?: TestingBehatElementLocator): T | null {
        let startingElement: HTMLElement | undefined = document.body;
        let queryPrefix = '';

        if (referenceLocator) {
            startingElement = TestingBehatDomUtils.findElementBasedOnText(referenceLocator, {
                onlyClickable: false,
                containerName: '',
            });

            if (!startingElement) {
                return null;
            }
        } else {
            // Searching the whole DOM, search only in visible pages.
            queryPrefix = '.ion-page:not(.ion-page-hidden) ';
        }

        return Array.from(startingElement.querySelectorAll(`${queryPrefix}${selector}`)).pop() as T
            ?? startingElement.closest(selector) as T;
    }

    /**
     * Logs information from this Behat runtime JavaScript, including the time and the 'BEHAT'
     * keyword so we can easily filter for it if needed.
     */
    log(...args: unknown[]): void {
        const now = new Date();
        const nowFormatted = String(now.getHours()).padStart(2, '0') + ':' +
                String(now.getMinutes()).padStart(2, '0') + ':' +
                String(now.getSeconds()).padStart(2, '0') + '.' +
                String(now.getMilliseconds()).padStart(2, '0');

        console.log('BEHAT: ' + nowFormatted, ...args); // eslint-disable-line no-console
    }

    /**
     * Flush pending notifications.
     */
    flushNotifications(): void {
        (LocalNotifications as unknown as LocalNotificationsMock).flush();
    }

    /**
     * Check a notification is present.
     *
     * @param title Title of the notification
     * @returns YES or NO: depending on the result.
     */
    async notificationIsPresentWithText(title: string): Promise<string> {
        const notifications = await LocalNotifications.getAllTriggered();

        const notification = notifications.find((notification) => notification.title?.includes(title));

        if (!notification) {
            return 'NO';
        }

        if (!notification.id) {
            // Cannot check but has been triggered.
            return 'YES';
        }

        return (await LocalNotifications.isPresent(notification.id)) ? 'YES' : 'NO';
    }

    /**
     * Check a notification is scheduled.
     *
     * @param title Title of the notification
     * @param date Scheduled notification date.
     * @returns YES or NO: depending on the result.
     */
    async notificationIsScheduledWithText(title: string, date?: number): Promise<string> {
        const notifications = await LocalNotifications.getAllScheduled();

        const notification = notifications.find(
            (notification) => notification.title?.includes(title) && (!date || notification.trigger?.at?.getTime() === date),
        );

        return notification ? 'YES' : 'NO';
    }

    /**
     * Close notification.
     *
     * @param title Title of the notification
     * @returns OK or ERROR
     */
    async closeNotification(title: string): Promise<string> {
        const notifications = await LocalNotifications.getAllTriggered();

        const notification = notifications.find((notification) => notification.title?.includes(title));

        if (!notification || !notification.id) {
            return `ERROR: Notification with title ${title} cannot be closed`;
        }

        await LocalNotifications.clear(notification.id);

        return 'OK';
    }

    /**
     * Swipe in the app.
     *
     * @param direction Left or right.
     * @param locator Element locator to swipe. If not specified, swipe in the first ion-content found.
     * @returns OK if successful, or ERROR: followed by message
     */
    swipe(direction: string, locator?: TestingBehatElementLocator): string {
        this.log('Action - Swipe', { direction, locator });

        if (locator) {
            // Locator specified, try to find swiper-container first.
            const swiperContainer = this.getElement<{ swiper: Swiper }>('swiper-container', locator);

            if (swiperContainer) {
                direction === 'left' ? swiperContainer.swiper.slideNext() : swiperContainer.swiper.slidePrev();

                return 'OK';
            }
        }

        // No locator specified or swiper-container not found, search swipe navigation now.
        const ionContent = this.getElement<{ swipeNavigation: CoreSwipeNavigationDirective }>(
            'ion-content.uses-swipe-navigation',
            locator,
        );

        if (!ionContent) {
            return 'ERROR: Element to swipe not found.';
        }

        direction === 'left' ? ionContent.swipeNavigation.swipeLeft() : ionContent.swipeNavigation.swipeRight();

        return 'OK';
    }

    /**
     * Wait for toast to be dismissed in the app.
     *
     * @returns Promise resolved when toast has been dismissed.
     */
    async waitToastDismiss(): Promise<void> {
        await CoreUtils.ignoreErrors(ToastController.dismiss());
    }

}

export const TestingBehatRuntime = makeSingleton(TestingBehatRuntimeService);

export type BehatTestsWindow = Window & {
    M?: { // eslint-disable-line @typescript-eslint/naming-convention
        util?: {
            pending_js?: string[]; // eslint-disable-line @typescript-eslint/naming-convention
        };
    };
};

export type TestingBehatFindOptions = {
    containerName?: string;
    onlyClickable?: boolean;
};

export type TestingBehatElementLocator = {
    text: string;
    within?: TestingBehatElementLocator;
    near?: TestingBehatElementLocator;
    selector?: string;
};

export type TestingBehatInitOptions = {
    skipOnBoarding?: boolean;
    configOverrides?: Partial<EnvironmentConfig>;
};
