import { inject, injectable } from '@theia/core/shared/inversify';
import URI from '@theia/core/lib/common/uri';
import { Emitter } from '@theia/core/lib/common/event';
import { notEmpty } from '@theia/core/lib/common/objects';
import { FileService } from '@theia/filesystem/lib/browser/file-service';
import { FileChangeType } from '@theia/filesystem/lib/common/files';
import { WorkspaceService } from '@theia/workspace/lib/browser/workspace-service';
import {
  Disposable,
  DisposableCollection,
} from '@theia/core/lib/common/disposable';
import { FrontendApplicationContribution } from '@theia/core/lib/browser/frontend-application-contribution';
import { Sketch, SketchesService } from '../common/protocol';
import { ConfigServiceClient } from './config/config-service-client';
import {
  SketchContainer,
  SketchesError,
  SketchRef,
} from '../common/protocol/sketches-service';
import {
  ARDUINO_CLOUD_FOLDER,
  REMOTE_SKETCHBOOK_FOLDER,
} from './utils/constants';
import * as monaco from '@theia/monaco-editor-core';
import { Deferred } from '@theia/core/lib/common/promise-util';
import { FrontendApplicationStateService } from '@theia/core/lib/browser/frontend-application-state';

const READ_ONLY_FILES = ['sketch.json'];
const READ_ONLY_FILES_REMOTE = ['thingProperties.h', 'thingsProperties.h'];

export type CurrentSketch = Sketch | 'invalid';
export namespace CurrentSketch {
  export function isValid(arg: CurrentSketch | undefined): arg is Sketch {
    return !!arg && arg !== 'invalid';
  }
}

@injectable()
export class SketchesServiceClientImpl
  implements FrontendApplicationContribution
{
  @inject(FileService)
  private readonly fileService: FileService;
  @inject(SketchesService)
  private readonly sketchesService: SketchesService;
  @inject(WorkspaceService)
  private readonly workspaceService: WorkspaceService;
  @inject(ConfigServiceClient)
  private readonly configService: ConfigServiceClient;
  @inject(FrontendApplicationStateService)
  private readonly appStateService: FrontendApplicationStateService;

  private sketches = new Map<string, SketchRef>();
  private onSketchbookDidChangeEmitter = new Emitter<{
    created: SketchRef[];
    removed: SketchRef[];
  }>();
  readonly onSketchbookDidChange = this.onSketchbookDidChangeEmitter.event;
  private currentSketchDidChangeEmitter = new Emitter<CurrentSketch>();
  readonly onCurrentSketchDidChange = this.currentSketchDidChangeEmitter.event;

  private toDisposeBeforeWatchSketchbookDir = new DisposableCollection();
  private toDispose = new DisposableCollection(
    this.onSketchbookDidChangeEmitter,
    this.currentSketchDidChangeEmitter,
    this.toDisposeBeforeWatchSketchbookDir
  );

  private _currentSketch: CurrentSketch | undefined;
  private _currentIdeTempFolderUri: URI | undefined;
  private currentSketchLoaded = new Deferred<CurrentSketch>();

  onStart(): void {
    const sketchDirUri = this.configService.tryGetSketchDirUri();
    this.watchSketchbookDir(sketchDirUri);
    const refreshCurrentSketch = async () => {
      await this.workspaceService.ready;
      const currentSketch = await this.loadCurrentSketch();
      const ideTempFolderUri = await this.getIdeTempFolderUriForSketch(
        currentSketch
      );
      this.useCurrentSketch(currentSketch, ideTempFolderUri);
    };
    this.toDispose.push(
      this.configService.onDidChangeSketchDirUri((sketchDirUri) => {
        this.watchSketchbookDir(sketchDirUri);
        refreshCurrentSketch();
      })
    );
    this.appStateService
      .reachedState('started_contributions')
      .then(refreshCurrentSketch);
  }

  private async watchSketchbookDir(
    sketchDirUri: URI | undefined
  ): Promise<void> {
    this.toDisposeBeforeWatchSketchbookDir.dispose();
    if (!sketchDirUri) {
      return;
    }
    const container = await this.sketchesService.getSketches({
      uri: sketchDirUri.toString(),
    });
    for (const sketch of SketchContainer.toArray(container)) {
      this.sketches.set(sketch.uri, sketch);
    }
    this.toDisposeBeforeWatchSketchbookDir.pushAll([
      Disposable.create(() => this.sketches.clear()),
      // Watch changes in the sketchbook to update `File` > `Sketchbook` menu items.
      this.fileService.watch(sketchDirUri, {
        recursive: true,
        excludes: [],
      }),
      this.fileService.onDidFilesChange(async (event) => {
        for (const { type, resource } of event.changes) {
          // The file change events have higher precedence in the current sketch over the sketchbook.
          if (
            CurrentSketch.isValid(this._currentSketch) &&
            new URI(this._currentSketch.uri).isEqualOrParent(resource)
          ) {
            // https://github.com/arduino/arduino-ide/pull/1351#pullrequestreview-1086666656
            // On a sketch file rename, the FS watcher will contain two changes:
            //  - Deletion of the original file,
            //  - Update of the new file,
            // Hence, `UPDATE` events must be processed but only and if only there is a `DELETED` change in the same event.
            // Otherwise, IDE2 would ask CLI to reload the sketch content on every save event in IDE2.
            if (type === FileChangeType.UPDATED && event.changes.length === 1) {
              // If the event contains only one `UPDATE` change, it cannot be a rename.
              return;
            }

            let reloadedSketch: Sketch | undefined = undefined;
            try {
              reloadedSketch = await this.sketchesService.loadSketch(
                this._currentSketch.uri
              );
            } catch (err) {
              if (!SketchesError.NotFound.is(err)) {
                throw err;
              }
            }

            if (!reloadedSketch) {
              return;
            }

            if (!Sketch.sameAs(this._currentSketch, reloadedSketch)) {
              const ideTempFolderUri = await this.getIdeTempFolderUriForSketch(
                reloadedSketch
              );
              this.useCurrentSketch(reloadedSketch, ideTempFolderUri, true);
            }
            return;
          }
          // We track main sketch files changes only. // TODO: check sketch folder changes. One can rename the folder without renaming the `.ino` file.
          if (sketchDirUri.isEqualOrParent(resource)) {
            if (Sketch.isSketchFile(resource)) {
              if (type === FileChangeType.ADDED) {
                try {
                  const toAdd = await this.sketchesService.loadSketch(
                    resource.parent.toString()
                  );
                  if (!this.sketches.has(toAdd.uri)) {
                    console.log(
                      `New sketch '${toAdd.name}' was created in sketchbook '${sketchDirUri}'.`
                    );
                    this.sketches.set(toAdd.uri, toAdd);
                    this.fireSoon(toAdd, 'created');
                  }
                } catch {}
              } else if (type === FileChangeType.DELETED) {
                const uri = resource.parent.toString();
                const toDelete = this.sketches.get(uri);
                if (toDelete) {
                  console.log(
                    `Sketch '${toDelete.name}' was removed from sketchbook '${sketchDirUri}'.`
                  );
                  this.sketches.delete(uri);
                  this.fireSoon(toDelete, 'removed');
                }
              }
            }
          }
        }
      }),
    ]);
  }

  private async getIdeTempFolderUriForSketch(
    sketch: CurrentSketch
  ): Promise<URI | undefined> {
    if (CurrentSketch.isValid(sketch)) {
      const uri = await this.sketchesService.getIdeTempFolderUri(sketch);
      return new URI(uri);
    }
    return undefined;
  }

  private useCurrentSketch(
    currentSketch: CurrentSketch,
    ideTempFolderUri: URI | undefined,
    reassignPromise = false
  ) {
    this._currentSketch = currentSketch;
    this._currentIdeTempFolderUri = ideTempFolderUri;
    if (reassignPromise) {
      this.currentSketchLoaded = new Deferred();
    }
    this.currentSketchLoaded.resolve(this._currentSketch);
    this.currentSketchDidChangeEmitter.fire(this._currentSketch);
  }

  onStop(): void {
    this.toDispose.dispose();
  }

  private async loadCurrentSketch(): Promise<CurrentSketch> {
    const sketches = (
      await Promise.all(
        this.workspaceService
          .tryGetRoots()
          .map(({ resource }) =>
            this.sketchesService.getSketchFolder(resource.toString())
          )
      )
    ).filter(notEmpty);
    if (!sketches.length) {
      return 'invalid';
    }
    if (sketches.length > 1) {
      console.log(
        `Multiple sketch folders were found in the workspace. Falling back to the first one. Sketch folders: ${JSON.stringify(
          sketches
        )}`
      );
    }
    return sketches[0];
  }

  async currentSketch(): Promise<CurrentSketch> {
    return this.currentSketchLoaded.promise;
  }

  tryGetCurrentSketch(): CurrentSketch | undefined {
    return this._currentSketch;
  }

  async currentSketchFile(): Promise<string | undefined> {
    const currentSketch = await this.currentSketch();
    if (CurrentSketch.isValid(currentSketch)) {
      return currentSketch.mainFileUri;
    }
    return undefined;
  }

  private fireSoonHandle?: number;
  private bufferedSketchbookEvents: {
    type: 'created' | 'removed';
    sketch: SketchRef;
  }[] = [];

  private fireSoon(sketch: SketchRef, type: 'created' | 'removed'): void {
    this.bufferedSketchbookEvents.push({ type, sketch });

    if (typeof this.fireSoonHandle === 'number') {
      window.clearTimeout(this.fireSoonHandle);
    }

    this.fireSoonHandle = window.setTimeout(() => {
      const event: { created: SketchRef[]; removed: SketchRef[] } = {
        created: [],
        removed: [],
      };
      for (const { type, sketch } of this.bufferedSketchbookEvents) {
        if (type === 'created') {
          event.created.push(sketch);
        } else {
          event.removed.push(sketch);
        }
      }
      this.onSketchbookDidChangeEmitter.fire(event);
      this.bufferedSketchbookEvents.length = 0;
    }, 100);
  }

  /**
   * `true` if the `uri` is not contained in any of the opened workspaces. Otherwise, `false`.
   */
  isReadOnly(uri: URI | monaco.Uri | string): boolean {
    const toCheck = uri instanceof URI ? uri : new URI(uri);
    if (toCheck.scheme === 'user-storage') {
      return false;
    }

    if (
      this._currentIdeTempFolderUri &&
      this._currentIdeTempFolderUri.resolve('launch.json').toString() ===
        toCheck.toString()
    ) {
      return false;
    }

    const isCloudSketch = toCheck
      .toString()
      .includes(`${REMOTE_SKETCHBOOK_FOLDER}/${ARDUINO_CLOUD_FOLDER}`);

    const filesToCheck = [
      ...READ_ONLY_FILES,
      ...(isCloudSketch ? READ_ONLY_FILES_REMOTE : []),
    ];

    if (filesToCheck.includes(toCheck?.path?.base)) {
      return true;
    }
    const readOnly = !this.workspaceService
      .tryGetRoots()
      .some(({ resource }) => resource.isEqualOrParent(toCheck));
    return readOnly;
  }
}