/* This Source Code Form is subject to the terms of the Mozilla Public
 * License, v. 2.0. If a copy of the MPL was not distributed with this
 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */

import createDebug from 'debug';

const debug = createDebug('cloud');
const debugErrors = createDebug('cloud:errors');

import fs from 'node:fs';
import path from 'node:path';
import util from 'node:util';
import chokidar from 'chokidar';
// ci-info defines vendor constants (GITHUB_ACTIONS etc) dynamically -
// not detectable as named exports by the CJS/ESM interop lexer
import ciInfo from 'ci-info';
import awaitOnEE from '../../util/await-on-ee.ts';
import sleep from '../../util/sleep.ts';
import { getCloudHttpClient, getGot } from './http-client.ts';

const { isCI, name: ciName, GITHUB_ACTIONS } = ciInfo;

class ArtilleryCloudPlugin {
  // Untyped JS class - properties assigned dynamically
  [key: string]: any;

  constructor(
    _script: unknown,
    _events: unknown,
    { flags }: { flags: Record<string, any> }
  ) {
    this.enabled = false;

    const isInteractiveUse = typeof flags.record !== 'undefined';
    const enabledInCloudWorker =
      typeof process.env.WORKER_ID !== 'undefined' &&
      typeof process.env.ARTILLERY_CLOUD_API_KEY !== 'undefined';

    if (!isInteractiveUse && !enabledInCloudWorker) {
      return;
    }

    this.enabled = true;

    this.apiKey = flags.key || process.env.ARTILLERY_CLOUD_API_KEY;

    this.baseUrl =
      process.env.ARTILLERY_CLOUD_ENDPOINT || 'https://app.artillery.io';
    this.eventsEndpoint = `${this.baseUrl}/api/events`;
    this.whoamiEndpoint = `${this.baseUrl}/api/user/whoami`;
    this.getAssetUploadUrls = `${this.baseUrl}/api/asset-upload-urls`;
    this.pingEndpoint = `${this.baseUrl}/api/ping`;

    this.defaultHeaders = {
      'x-auth-token': this.apiKey
    };
    this.unprocessedLogsCounter = 0;
    // In-flight `_event()` POSTs (testrun:metrics, testrun:event,
    // testrun:aggregatereport, etc.). Drained in onShutdown before
    // testrun:end is sent, so the API doesn't see those POSTs as
    // stragglers landing after the run has been finalised.
    this.pendingEventRequests = 0;
    this.cancellationRequestedBy = '';

    let testEndInfo: any = {};

    this.testRunId =
      process.env.ARTILLERY_TEST_RUN_ID || global.artillery?.testRunId;

    if (isInteractiveUse) {
      global.artillery.globalEvents.on('test:init', async (testInfo) => {
        debug('test:init', testInfo);

        this.testRunId = testInfo.testRunId;

        const testRunUrl = `${this.baseUrl}/${this.orgId}/load-tests/${global.artillery.testRunId}`;
        testEndInfo.testRunUrl = testRunUrl;

        this.getLoadTestEndpoint = `${this.baseUrl}/api/load-tests/${this.testRunId}/status`;

        let ciURL = null;
        if (isCI && GITHUB_ACTIONS) {
          const { GITHUB_SERVER_URL, GITHUB_REPOSITORY, GITHUB_RUN_ID } =
            process.env;
          if (GITHUB_SERVER_URL && GITHUB_REPOSITORY && GITHUB_RUN_ID) {
            ciURL = `${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}`;
          }
        }

        const metadata = Object.assign({}, testInfo.metadata, {
          isCI,
          ciName,
          ciURL
        });

        await this._event('testrun:init', {
          metadata: metadata
        });
        this.setGetLoadTestInterval = this.setGetStatusInterval();

        if (typeof testInfo.flags.note !== 'undefined') {
          await this._event('testrun:addnote', { text: testInfo.flags.note });
        }

        this.uploading = 0;
      });

      global.artillery.globalEvents.on('phaseStarted', async (phase) => {
        await this._event('testrun:event', {
          eventName: 'phaseStarted',
          eventAttributes: phase
        });
      });

      global.artillery.globalEvents.on('phaseCompleted', async (phase) => {
        await this._event('testrun:event', {
          eventName: 'phaseCompleted',
          eventAttributes: phase
        });
      });

      global.artillery.globalEvents.on('stats', async (report) => {
        debug('stats', new Date());
        const ts = Number(report.period);
        await this._event('testrun:metrics', { report, ts });
      });

      global.artillery.globalEvents.on('done', async (report) => {
        debug('done');
        debug(
          'testrun:aggregatereport: payload size:',
          JSON.stringify(report).length
        );
        await this._event('testrun:aggregatereport', { aggregate: report });
      });

      global.artillery.globalEvents.on('checks', async (checks) => {
        debug('checks');
        await this._event('testrun:checks', { checks });
      });

      global.artillery.globalEvents.on('logLines', async (lines, ts) => {
        debug('logLines event', ts);
        this.unprocessedLogsCounter += 1;

        let text = '';

        try {
          JSON.stringify(lines);
        } catch (stringifyErr) {
          console.log('Could not serialize console log');
          console.log(stringifyErr);
        }
        for (const args of lines) {
          text += util.format(...Object.keys(args).map((k) => args[k])) + '\n';
        }

        try {
          await this._event('testrun:textlog', { lines: text, ts });
        } catch (err) {
          debugErrors(err);
        } finally {
          this.unprocessedLogsCounter -= 1;
        }

        debug('last 100 characters:');
        debug(text.slice(text.length - 100, text.length));
      });

      global.artillery.globalEvents.on('metadata', async (metadata) => {
        await this._event('testrun:addmetadata', {
          metadata
        });
      });
    } // isInteractiveUse

    global.artillery.ext({
      ext: 'beforeExit',
      method: async ({ testInfo, report }) => {
        debug('beforeExit');
        testEndInfo = {
          ...testEndInfo,
          ...testInfo,
          report
        };
      }
    });

    // Send test end events just before the CLI shuts down. This ensures that all console
    // output has been captured and sent to the dashboard.
    global.artillery.ext({
      ext: 'onShutdown',
      method: async (opts) => {
        if (!this.enabled || this.off) {
          return;
        }

        if (isInteractiveUse) {
          clearInterval(this.setGetLoadTestInterval);

          // Wait for the last logLines events to be processed, as they can sometimes finish processing after shutdown has finished
          await awaitOnEE(
            global.artillery.globalEvents,
            'logLines',
            200,
            1 * 1000 //wait at most 1 second for a final log lines event emitter to be fired
          );
        }

        await this.waitOnUnprocessedLogs(5 * 60 * 1000); //just waiting for ee is not enough, as the api call takes time

        // Drain any other in-flight event POSTs (stats / done / phase
        // events / addmetadata / ...) so they reach the API before
        // testrun:end finalises the run. EventEmitter.emit does not
        // await async listeners, so without this drain the POSTs race
        // testrun:end and the trailing ones get rejected.
        await this.waitOnPendingEventRequests(60 * 1000);

        if (isInteractiveUse) {
          await this._event('testrun:end', {
            ts: testEndInfo.endTime,
            exitCode: global.artillery.suggestedExitCode || opts.exitCode,
            isEarlyStop: !!opts.earlyStop,
            report: testEndInfo.report
          });

          console.log('\n');
          if (this.cancellationRequestedBy) {
            console.log(`Test run stopped by ${this.cancellationRequestedBy}.`);
          }
          console.log(`Run URL: ${testEndInfo.testRunUrl}`);
        }
      }
    });
  }

  async init() {
    this.request = await getCloudHttpClient();

    if (!this.apiKey) {
      const err = new Error();
      err.name = 'CloudAPIKeyMissing';
      this.off = true;
      throw err;
    }

    let res;
    let body;
    try {
      res = await this.request.get(this.whoamiEndpoint, {
        headers: this.defaultHeaders,
        retry: { limit: 0 }
      });

      body = JSON.parse(res.body);
      debug(res.body);
      this.orgId = body.activeOrg;
    } catch (err) {
      this.off = true;
      throw err;
    }

    if (res.statusCode === 401) {
      const err = new Error();
      err.name = 'APIKeyUnauthorized';
      this.off = true;
      throw err;
    }

    let postSucceeded = false;
    try {
      res = await this.request.post(this.pingEndpoint, {
        headers: this.defaultHeaders
      });

      if (res.statusCode === 200) {
        postSucceeded = true;
      }
    } catch (_err) {
      this.off = true;
    }

    if (!postSucceeded) {
      const err = new Error();
      err.name = 'PingFailed';
      this.off = true;
      throw err;
    }

    console.log('Artillery Cloud reporting is configured for this test run');
    console.log(
      `Run URL: ${this.baseUrl}/${this.orgId}/load-tests/${global.artillery.testRunId}`
    );

    this.user = {
      id: body.id,
      email: body.email
    };

    const outputDir =
      process.env.PLAYWRIGHT_TRACING_OUTPUT_DIR ||
      `/tmp/${global.artillery.testRunId}/`;

    try {
      fs.mkdirSync(outputDir, { recursive: true });
    } catch (_err) {}

    const watcher = chokidar.watch(outputDir, {
      ignored: /(^|[/\\])\../, // ignore dotfiles
      persistent: true,
      ignorePermissionErrors: true,
      ignoreInitial: true,
      awaitWriteFinish: {
        stabilityThreshold: 2000,
        pollInterval: 500
      }
    });

    watcher.on('add', (fp) => {
      if (path.basename(fp).startsWith('trace-') && fp.endsWith('.zip')) {
        this.uploading++;
        this._uploadAsset(fp);
      }
    });
  }

  async _uploadAsset(localFilename: string) {
    const filename = path.basename(localFilename);

    try {
      // Get upload URL
      const payload = {
        testRunId: this.testRunId,
        filenames: [filename]
      };
      debug(payload);

      let url;
      try {
        const res = await this.request.post(this.getAssetUploadUrls, {
          headers: this.defaultHeaders,
          json: payload
        });

        if (res.statusCode !== 200) {
          console.error(
            `Could not get upload URL for Playwright trace recording: ${filename} (HTTP ${res.statusCode})`
          );
          debug('asset-upload-urls body:', String(res.body).slice(0, 500));
          return;
        }

        const body = JSON.parse(res.body);
        url = body.urls && body.urls[filename];
      } catch (caughtErr) {
        const err = caughtErr as NodeJS.ErrnoException;
        console.error(
          'Could not get upload URL for Playwright trace recording:',
          filename,
          err.code || err.name,
          err.message
        );
        debugErrors(err.stack);
        return;
      }

      if (!url) {
        console.error(
          'Could not get upload URL for Playwright trace recording'
        );
        return;
      }

      // Upload file using vanilla got client.
      // Here we want:
      //   - `throwHttpErrors: true` (got's default): non-2xx throws
      //   - `retry: { limit: 0 }`: no got-level retry. Streams can't
      //     be replayed
      //   - explicit Content-Length: avoids Transfer-Encoding: chunked,
      //     which some corporate proxies / TLS interceptors handle
      //     badly on PUT.
      //
      const got = await getGot();

      let size;
      try {
        size = fs.statSync(localFilename).size;
      } catch (err) {
        debugErrors('could not stat trace file:', err);
        return;
      }

      try {
        const response = await got.put(url, {
          body: fs.createReadStream(localFilename),
          headers: { 'content-length': String(size) },
          retry: { limit: 0 },
          timeout: { request: 5 * 60 * 1000 }
        });
      } catch (caughtErr) {
        const error = caughtErr as NodeJS.ErrnoException & {
          response?: { statusCode?: number; headers?: unknown; body?: unknown };
        };
        console.error(
          'Failed to upload Playwright trace recording:',
          filename,
          error.code || error.name,
          error.message
        );
        if (error.response) {
          debugErrors('S3 status:', error.response.statusCode);
          debugErrors('S3 headers:', error.response.headers);
          debugErrors('S3 body:', String(error.response.body).slice(0, 500));
        }
        debugErrors(error.stack);
      }
    } finally {
      this.uploading--;
      try {
        fs.unlinkSync(localFilename);
      } catch (err) {
        debug(err);
      }
    }
  }

  async waitOnUnprocessedLogs(maxWaitTime: number) {
    let waitedTime = 0;
    while (
      (this.unprocessedLogsCounter > 0 || this.uploading > 0) &&
      waitedTime < maxWaitTime
    ) {
      debug('waiting on unprocessed logs');
      await sleep(500);
      waitedTime += 500;
    }

    return true;
  }

  async waitOnPendingEventRequests(maxWaitTime: number) {
    let waitedTime = 0;
    while (this.pendingEventRequests > 0 && waitedTime < maxWaitTime) {
      debug('waiting on pending event requests', this.pendingEventRequests);
      await sleep(500);
      waitedTime += 500;
    }

    return true;
  }

  setGetStatusInterval() {
    const interval = setInterval(async () => {
      if (this.cancellationRequestedBy) {
        return;
      }
      const res = await this._getLoadTestStatus();

      if (!res) {
        debug('No response from Artillery Cloud get status');
        return;
      }

      if (res.status !== 'CANCELLATION_REQUESTED') {
        return;
      }

      console.log(
        `WARNING: Artillery Cloud user ${res.cancelledBy} requested to stop the test. Stopping test run - this may take a few seconds.`
      );
      this.cancellationRequestedBy = res.cancelledBy;
      global.artillery.suggestedExitCode = 8;
      await global.artillery.shutdown({ earlyStop: true });
    }, 5000);

    return interval;
  }

  async _getLoadTestStatus() {
    debug('☁️', 'Getting load test status');

    try {
      const res = await this.request.get(this.getLoadTestEndpoint, {
        headers: this.defaultHeaders
      });

      return JSON.parse(res.body);
    } catch (error) {
      debug(error);
    }
  }

  async _event(eventName: string, eventPayload: Record<string, any>) {
    debug('☁️', eventName, eventPayload);

    this.pendingEventRequests += 1;
    try {
      const res = await this.request.post(this.eventsEndpoint, {
        headers: this.defaultHeaders,
        json: {
          eventType: eventName,
          eventData: Object.assign({}, eventPayload, {
            testRunId: this.testRunId
          })
        },
        retry: { limit: 2 }
      });

      if (res.statusCode !== 200) {
        if (res.statusCode === 401) {
          console.log(
            'Error: API key is invalid. Could not send test data to Artillery Cloud.'
          );
        } else {
          console.log('Error: error sending test data to Artillery Cloud');
          console.log('Test report may be incomplete');
        }
        let body;
        try {
          body = JSON.parse(res.body);
        } catch (_err) {}

        if (body?.requestId) {
          console.log('Request ID:', body.requestId);
        }
      }
      debug('☁️', eventName, 'sent');
    } catch (err) {
      debug(err);
    } finally {
      this.pendingEventRequests -= 1;
    }
  }

  cleanup(done: (err: Error | null) => void) {
    debug('cleaning up');
    done(null);
  }
}

export { ArtilleryCloudPlugin as Plugin };
