/**
 * @license
 * Copyright Google LLC All Rights Reserved.
 *
 * Use of this source code is governed by an MIT-style license that can be
 * found in the LICENSE file at https://angular.dev/license
 */

import { logging, schema, virtualFs } from '@angular-devkit/core';
import {
  EMPTY,
  Observable,
  Subject,
  concat,
  concatMap,
  defaultIfEmpty,
  from,
  ignoreElements,
  last,
  of,
  tap,
  throwError,
} from 'rxjs';
import { Engine, EngineHost, SchematicEngine } from '../engine';
import { UnsuccessfulWorkflowExecution } from '../exception/exception';
import { standardFormats } from '../formats';
import { DryRunEvent, DryRunSink } from '../sink/dryrun';
import { HostSink } from '../sink/host';
import { Sink } from '../sink/sink';
import { HostTree } from '../tree/host-tree';
import { Tree } from '../tree/interface';
import {
  LifeCycleEvent,
  RequiredWorkflowExecutionContext,
  Workflow,
  WorkflowExecutionContext,
} from './interface';

export interface BaseWorkflowOptions {
  host: virtualFs.Host;
  engineHost: EngineHost<{}, {}>;
  registry?: schema.CoreSchemaRegistry;

  force?: boolean;
  dryRun?: boolean;
}

/**
 * Base class for workflows. Even without abstract methods, this class should not be used without
 * surrounding some initialization for the registry and host. This class only adds life cycle and
 * dryrun/force support. You need to provide any registry and task executors that you need to
 * support.
 * See {@see NodeWorkflow} implementation for how to make a specialized subclass of this.
 * TODO: add default set of CoreSchemaRegistry transforms. Once the job refactor is done, use that
 *       as the support for tasks.
 *
 * @public
 */
export abstract class BaseWorkflow implements Workflow {
  protected _engine: Engine<{}, {}>;
  protected _engineHost: EngineHost<{}, {}>;
  protected _registry: schema.CoreSchemaRegistry;

  protected _host: virtualFs.Host;

  protected _reporter: Subject<DryRunEvent> = new Subject();
  protected _lifeCycle: Subject<LifeCycleEvent> = new Subject();

  protected _context: WorkflowExecutionContext[];

  protected _force: boolean;
  protected _dryRun: boolean;

  constructor(options: BaseWorkflowOptions) {
    this._host = options.host;
    this._engineHost = options.engineHost;

    if (options.registry) {
      this._registry = options.registry;
    } else {
      this._registry = new schema.CoreSchemaRegistry(standardFormats);
      this._registry.addPostTransform(schema.transforms.addUndefinedDefaults);
    }

    this._engine = new SchematicEngine(this._engineHost, this);

    this._context = [];

    this._force = options.force || false;
    this._dryRun = options.dryRun || false;
  }

  get context(): Readonly<WorkflowExecutionContext> {
    const maybeContext = this._context[this._context.length - 1];
    if (!maybeContext) {
      throw new Error('Cannot get context when workflow is not executing...');
    }

    return maybeContext;
  }
  get engine(): Engine<{}, {}> {
    return this._engine;
  }
  get engineHost(): EngineHost<{}, {}> {
    return this._engineHost;
  }
  get registry(): schema.SchemaRegistry {
    return this._registry;
  }
  get reporter(): Observable<DryRunEvent> {
    return this._reporter.asObservable();
  }
  get lifeCycle(): Observable<LifeCycleEvent> {
    return this._lifeCycle.asObservable();
  }

  protected _createSinks(): Sink[] {
    let error = false;

    const dryRunSink = new DryRunSink(this._host, this._force);
    const dryRunSubscriber = dryRunSink.reporter.subscribe((event) => {
      this._reporter.next(event);
      error = error || event.kind == 'error';
    });

    // We need two sinks if we want to output what will happen, and actually do the work.
    return [
      dryRunSink,
      // Add a custom sink that clean ourselves and throws an error if an error happened.
      {
        commit() {
          dryRunSubscriber.unsubscribe();
          if (error) {
            return throwError(new UnsuccessfulWorkflowExecution());
          }

          return of();
        },
      },

      // Only add a HostSink if this is not a dryRun.
      ...(!this._dryRun ? [new HostSink(this._host, this._force)] : []),
    ];
  }

  execute(
    options: Partial<WorkflowExecutionContext> & RequiredWorkflowExecutionContext,
  ): Observable<void> {
    const parentContext = this._context[this._context.length - 1];

    if (!parentContext) {
      this._lifeCycle.next({ kind: 'start' });
    }

    /** Create the collection and the schematic. */
    const collection = this._engine.createCollection(options.collection);
    // Only allow private schematics if called from the same collection.
    const allowPrivate =
      options.allowPrivate || (parentContext && parentContext.collection === options.collection);
    const schematic = collection.createSchematic(options.schematic, allowPrivate);

    const sinks = this._createSinks();

    this._lifeCycle.next({ kind: 'workflow-start' });

    const context = {
      ...options,
      debug: options.debug || false,
      logger: options.logger || (parentContext && parentContext.logger) || new logging.NullLogger(),
      parentContext,
    };
    this._context.push(context);

    return schematic
      .call(options.options, of(new HostTree(this._host)), { logger: context.logger })
      .pipe(
        concatMap((tree: Tree) => {
          // Process all sinks.
          return concat(
            from(sinks).pipe(
              concatMap((sink) => sink.commit(tree)),
              ignoreElements(),
            ),
            of(tree),
          );
        }),
        concatMap(() => {
          if (this._dryRun) {
            return EMPTY;
          }

          this._lifeCycle.next({ kind: 'post-tasks-start' });

          return this._engine
            .executePostTasks()
            .pipe(
              tap({ complete: () => this._lifeCycle.next({ kind: 'post-tasks-end' }) }),
              defaultIfEmpty(undefined),
              last(),
            );
        }),
        tap({
          complete: () => {
            this._lifeCycle.next({ kind: 'workflow-end' });
            this._context.pop();

            if (this._context.length == 0) {
              this._lifeCycle.next({ kind: 'end' });
            }
          },
        }),
      );
  }
}
