/**
 * @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.io/license
 */
import { SpawnOptions, spawn } from 'child_process';
import * as pidusage from 'pidusage';
import { Observable, Subject, from, timer } from 'rxjs';
import { concatMap, map, onErrorResumeNext, tap } from 'rxjs/operators';
import { Command } from './command';
import { AggregatedProcessStats, MonitoredProcess } from './interfaces';

const pidtree = require('pidtree');
const treeKill = require('tree-kill');

// Cleanup when the parent process exits.
const defaultProcessExitCb = () => { };
let processExitCb = defaultProcessExitCb;
process.on('exit', () => {
  processExitCb();
  processExitCb = defaultProcessExitCb;
});

export class LocalMonitoredProcess implements MonitoredProcess {
  private stats = new Subject<AggregatedProcessStats>();
  private stdout = new Subject<Buffer>();
  private stderr = new Subject<Buffer>();
  private pollingRate = 100;
  private elapsedTimer = 0;

  stats$: Observable<AggregatedProcessStats> = this.stats.asObservable();
  stdout$: Observable<Buffer> = this.stdout.asObservable();
  stderr$: Observable<Buffer> = this.stderr.asObservable();

  constructor(
    private command: Command,
    private useProcessTime = true,
  ) { }

  run(): Observable<number> {
    return new Observable(obs => {
      const { cmd, cwd, args } = this.command;
      const spawnOptions: SpawnOptions = { cwd, shell: true };

      if (!this.useProcessTime) {
        this.resetElapsedTimer();
      }
      // Spawn the process.
      const childProcess = spawn(cmd, args, spawnOptions);

      // Emit output and stats.
      childProcess.stdout.on('data', (data: Buffer) => this.stdout.next(data));
      childProcess.stderr.on('data', (data: Buffer) => this.stderr.next(data));

      const statsSubs = timer(0, this.pollingRate).pipe(
        concatMap(() => from(pidtree(childProcess.pid, { root: true }) as Promise<number[]>)),
        concatMap((pids: number[]) => from(pidusage(pids, { maxage: 5 * this.pollingRate }))),
        map(statsByProcess => {
          // Ignore the spawned shell in the total process number.
          const pids = Object.keys(statsByProcess)
            .filter(pid => pid != childProcess.pid.toString());
          const processes = pids.length;
          // We want most stats from the parent process.
          const { pid, ppid, ctime, elapsed, timestamp } = statsByProcess[childProcess.pid];

          // CPU and memory should be agreggated.
          let cpu = 0;
          let memory = 0;
          for (const pid of pids) {
            cpu += statsByProcess[pid].cpu;
            memory += statsByProcess[pid].memory;
          }

          const stats: AggregatedProcessStats = {
            processes,
            cpu,
            memory,
            pid,
            ppid,
            ctime,
            elapsed: this.useProcessTime ? elapsed : (Date.now() - this.elapsedTimer),
            timestamp,
          };

          return stats;
        }),
        tap(stats => this.stats.next(stats)),
        onErrorResumeNext(),
      ).subscribe();

      // Process event handling.

      // Killing processes cross platform can be hard, treeKill helps.
      const killChildProcess = () => {
        if (childProcess && childProcess.pid) {
          treeKill(childProcess.pid, 'SIGTERM');
        }
      };

      // Convert process exit codes and errors into observable events.
      const handleChildProcessExit = (code?: number, error?: Error) => {
        // Stop gathering stats and complete subjects.
        statsSubs.unsubscribe();
        this.stats.complete();
        this.stdout.complete();
        this.stderr.complete();

        // Kill hanging child processes and emit error/exit code.
        killChildProcess();
        if (error) {
          obs.error(error);
        }
        obs.next(code);
        obs.complete();
      };
      childProcess.once('exit', handleChildProcessExit);
      childProcess.once('error', (err) => handleChildProcessExit(1, err));
      processExitCb = killChildProcess;

      // Cleanup on unsubscription.
      return killChildProcess;
    });
  }

  resetElapsedTimer() {
    if (this.useProcessTime) {
      throw new Error(`Cannot reset elapsed timer when using process time. Set 'useProcessTime' to false.`);
    }

    this.elapsedTimer = Date.now();
  }
}
