/* eslint-disable @typescript-eslint/no-explicit-any */
// *****************************************************************************
// Copyright (C) 2022 STMicroelectronics and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************

import { CancellationToken, Emitter, Event, URI } from '@theia/core';
import { Range, Location, CancellationTokenSource } from '@theia/core/shared/vscode-languageserver-protocol';

import { MarkdownString } from '@theia/core/lib/common/markdown-rendering';
import { SimpleObservableCollection, TreeCollection, observableProperty } from '@theia/test/lib/common/collections';
import {
    TestController, TestExecutionState, TestFailure, TestItem,
    TestOutputItem, TestRun, TestRunProfile, TestState, TestStateChangedEvent
} from '@theia/test/lib/browser/test-service';
import { AccumulatingTreeDeltaEmitter, CollectionDelta, TreeDelta, TreeDeltaBuilder } from '@theia/test/lib/common/tree-delta';
import { timeout } from '@theia/core/lib/common/promise-util';

export class TestItemCollection extends TreeCollection<string, TestItemImpl, TestItemImpl | TestControllerImpl> {
    override add(item: TestItemImpl): TestItemImpl | undefined {
        item.realParent = this.owner;
        return super.add(item);
    }
}

export class TestItemImpl implements TestItem {
    constructor(readonly uri: URI, readonly id: string) {
        this._children = new TestItemCollection(this, (v: TestItemImpl) => v.path, (v: TestItemImpl) => v.deltaBuilder);
    }

    // eslint-disable-next-line @typescript-eslint/no-explicit-any
    protected notifyPropertyChange(property: keyof TestItemImpl, value: any): void {
        // eslint-disable-next-line @typescript-eslint/no-explicit-any
        const val: any = {};
        val[property] = value;
        if (this.path) {
            this.deltaBuilder?.reportChanged(this.path, val);
        }
    }

    _deltaBuilder: TreeDeltaBuilder<string, TestItemImpl> | undefined;
    get deltaBuilder(): TreeDeltaBuilder<string, TestItemImpl> | undefined {
        if (this._deltaBuilder) {
            return this._deltaBuilder;
        } else if (this.realParent) {
            this._deltaBuilder = this.realParent.deltaBuilder;
            return this._deltaBuilder;
        } else {
            return undefined;
        }
    }

    _path: string[] | undefined;

    get path(): string[] {
        if (this._path) {
            return this._path;
        } else if (this.realParent instanceof TestItemImpl) {
            this._path = [...this.realParent.path, this.id];
            return this._path;
        } else {
            return [this.id];
        }
    };

    private _parent?: TestItemImpl | TestControllerImpl;
    get realParent(): TestItemImpl | TestControllerImpl | undefined {
        return this._parent;
    }

    set realParent(v: TestItemImpl | TestControllerImpl | undefined) {
        this.iterate(item => {
            item._path = undefined;
            return true;
        });
        this._parent = v;
    }

    get parent(): TestItem | undefined {
        const realParent = this.realParent;
        if (realParent instanceof TestItemImpl) {
            return realParent;
        }
        return undefined;
    }

    get controller(): TestControllerImpl | undefined {
        if (this.realParent instanceof TestItemImpl) {
            return this.realParent.controller;
        }
        return this.realParent;
    }

    protected iterate(toDo: (v: TestItemImpl) => boolean): boolean {
        if (toDo(this)) {
            for (let i = 0; i < this._children.values.length; i++) {
                if (!this._children.values[i].iterate(toDo)) {
                    return false;
                }
            }
            return true;
        } else {
            return false;
        }
    }

    @observableProperty('notifyPropertyChange')
    label: string = '';

    @observableProperty('notifyPropertyChange')
    range?: Range;

    @observableProperty('notifyPropertyChange')
    sortKey?: string | undefined;

    @observableProperty('notifyPropertyChange')
    tags: string[] = [];

    @observableProperty('notifyPropertyChange')
    busy: boolean = false;

    @observableProperty('notifyPropertyChange')
    canResolveChildren: boolean = false;

    @observableProperty('notifyPropertyChange')
    description?: string | undefined;

    @observableProperty('notifyPropertyChange')
    error?: string | MarkdownString | undefined;

    _children: TestItemCollection;
    get tests(): readonly TestItemImpl[] {
        return this._children.values;
    }

    resolveChildren(): void {
        // do nothing
    }
}

export class TestRunImpl implements TestRun {
    private testStates: Map<TestItem, TestState> = new Map();
    private outputIndices: Map<TestItem, number[]> = new Map();
    private outputs: TestOutputItem[] = [];
    private onDidChangePropertyEmitter = new Emitter<{ name?: string; isRunning?: boolean; }>();
    onDidChangeProperty: Event<{ name?: string; isRunning?: boolean; }> = this.onDidChangePropertyEmitter.event;
    private cts: CancellationTokenSource;

    constructor(readonly controller: TestControllerImpl, readonly id: string, name: string) {
        this.name = name;
        this.isRunning = false;
        this.start();
    }

    private start(): void {
        this.cts = new CancellationTokenSource();
        Promise.allSettled(this.collectTestsForRun().map(item => this.simulateTestRun(item, this.cts.token))).then(() => this.ended());
    }

    collectTestsForRun(): TestItemImpl[] {
        const result: TestItemImpl[] = [];
        this.collectTests(this.controller.tests, result);
        return result;
    }

    collectTests(tests: readonly TestItemImpl[], result: TestItemImpl[]): void {
        tests.forEach(test => this.collectTest(test, result));
    }

    collectTest(test: TestItemImpl, result: TestItemImpl[]): void {
        if (test.tests.length > 0) {
            this.collectTests(test.tests, result);
        } else if (Math.random() < 0.8) {
            result.push(test);
        }
    }

    simulateTestRun(item: TestItemImpl, token: CancellationToken): Promise<void> {
        let outputCounter = 0;
        let messageCounter = 0;
        return timeout(Math.random() * 3000, token)
            .then(() => this.setTestState(item, { state: TestExecutionState.Queued }))
            .then(() => timeout(Math.random() * 3000, token))
            .then(() => this.setTestState(item, { state: TestExecutionState.Running }))
            .then(() => timeout(Math.random() * 3000, token))
            .then(() => {
                this.appendOutput(`Output from Test ${item.label} nr ${outputCounter++}`);
            })
            .then(() => timeout(Math.random() * 3000, token))
            .then(() => {
                this.appendOutput(`Output from Test ${item.label} nr ${outputCounter++}`);
            })
            .then(() => timeout(Math.random() * 3000, token))
            .then(() => {
                this.appendOutput(`Output from Test ${item.label} nr ${outputCounter++}`);
            })
            .then(() => timeout(Math.random() * 3000, token))
            .then(() => {
                this.appendOutput(`Output from Test ${item.label} nr ${outputCounter++}`);
            }).then(() => {
                const random = Math.random();
                if (random > 0.9) {
                    this.setTestState(item, { state: TestExecutionState.Skipped });
                } else if (random > 0.8) {
                    const failure: TestFailure = {
                        state: TestExecutionState.Errored,
                        messages: [
                            {
                                message: {
                                    value: `**Error** from Test ${item.label} nr ${messageCounter++}`
                                },
                                location: {
                                    uri: item.uri.toString(),
                                    range: item.range!
                                },
                            }
                        ],
                        duration: 33
                    };
                    this.setTestState(item, failure);
                } else if (random > 0.7) {
                    const failure: TestFailure = {
                        state: TestExecutionState.Failed,
                        messages: [
                            {
                                message: {
                                    value: `**Failure** from Test ${item.label} nr ${messageCounter++}`
                                },
                                location: {
                                    uri: item.uri.toString(),
                                    range: item.range!
                                },
                            }
                        ],
                        duration: 33
                    };
                    this.setTestState(item, failure);
                } else {
                    this.setTestState(item, { state: TestExecutionState.Passed });
                }
            });
    }

    @observableProperty('notifyPropertyChange')
    isRunning: boolean;

    @observableProperty('notifyPropertyChange')
    name: string;

    protected notifyPropertyChange(property: 'name' | 'isRunning', value: unknown): void {
        // eslint-disable-next-line @typescript-eslint/no-explicit-any
        const val: any = {};
        val[property] = value;
        this.onDidChangePropertyEmitter.fire(val);
    }

    cancel(): void {
        this.cts.cancel();
    }

    getTestState(item: TestItem): TestState | undefined {
        return this.testStates.get(item);
    }

    private onDidChangeTestStateEmitter: Emitter<TestStateChangedEvent[]> = new Emitter();
    onDidChangeTestState: Event<TestStateChangedEvent[]> = this.onDidChangeTestStateEmitter.event;

    getOutput(item?: TestItem | undefined): readonly TestOutputItem[] {
        if (!item) {
            return this.outputs;
        } else {
            const indices = this.outputIndices.get(item);
            if (!indices) {
                return [];
            } else {
                return indices.map(index => this.outputs[index]);
            }
        }
    }
    private onDidChangeTestOutputEmitter: Emitter<[TestItem | undefined, TestOutputItem][]> = new Emitter();
    onDidChangeTestOutput: Event<[TestItem | undefined, TestOutputItem][]> = this.onDidChangeTestOutputEmitter.event;

    setTestState<T extends TestState>(test: TestItemImpl, newState: TestState): void {
        const oldState = this.testStates.get(test);
        this.testStates.set(test, newState);
        this.onDidChangeTestStateEmitter.fire([{
            oldState: oldState, newState: newState, test: test
        }]);
    }

    appendOutput(text: string, location?: Location, item?: TestItem): void {
        const output = {
            output: text,
            location: location
        };
        this.outputs.push(output);
        if (item) {
            let indices = this.outputIndices.get(item);
            if (!indices) {
                indices = [];
                this.outputIndices.set(item, indices);
            }
            indices.push(this.outputs.length - 1);
        }
        this.onDidChangeTestOutputEmitter.fire([[item, output]]);
    }

    get items(): readonly TestItem[] {
        return [...this.testStates.keys()];
    }

    ended(): void {
        const stateEvents: TestStateChangedEvent[] = [];
        this.testStates.forEach((state, item) => {
            if (state.state <= TestExecutionState.Running) {
                stateEvents.push({
                    oldState: state,
                    newState: undefined,
                    test: item
                });
                this.testStates.delete(item);
            }
        });
        if (stateEvents.length > 0) {
            this.onDidChangeTestStateEmitter.fire(stateEvents);
        }
        this.isRunning = false;
    }
}

export class TestControllerImpl implements TestController {
    private _profiles = new SimpleObservableCollection<TestRunProfile>();
    private _runs = new SimpleObservableCollection<TestRun>();
    readonly deltaBuilder = new AccumulatingTreeDeltaEmitter<string, TestItemImpl>(300);
    items = new TestItemCollection(this, item => item.path, () => this.deltaBuilder);

    constructor(readonly id: string, readonly label: string) {
    }

    refreshTests(token: CancellationToken): Promise<void> {
        // not implemented
        return Promise.resolve();
    }

    get testRunProfiles(): readonly TestRunProfile[] {
        return this._profiles.values;
    }

    addProfile(profile: TestRunProfile): void {
        this._profiles.add(profile);
    }

    onProfilesChanged: Event<CollectionDelta<TestRunProfile, TestRunProfile>> = this._profiles.onChanged;

    get testRuns(): readonly TestRun[] {
        return this._runs.values;
    }

    addRun(run: TestRun): void {
        this._runs.add(run);
    }

    onRunsChanged: Event<CollectionDelta<TestRun, TestRun>> = this._runs.onChanged;
    get tests(): readonly TestItemImpl[] {
        return this.items.values;
    }
    onItemsChanged: Event<TreeDelta<string, TestItemImpl>[]> = this.deltaBuilder.onDidFlush;

    canResolveChildren: boolean = false;

    resolveChildren(item?: TestItem): void {
        // nothing to do
    }

    clearRuns(): void {
        this._runs.clear();
    }
}
