/**
 * @license
 * Copyright 2022 Google LLC
 * SPDX-License-Identifier: Apache-2.0
 */

import * as fs from 'fs/promises';
import * as pathlib from 'path';
import * as assert from 'node:assert';
import * as crypto from 'node:crypto';
import * as selfsigned from 'selfsigned';
import * as x509 from '@peculiar/x509';
import {test, before, after, beforeEach, afterEach} from 'node:test';
import {fileURLToPath} from 'url';
import {ExitResult, WireitTestRig} from './util/test-rig.js';
import {registerCommonCacheTests} from './cache-common.js';
import {
  FakeGitHubActionsCacheServer,
  type FakeGitHubActionsCacheServerMetrics,
} from './util/fake-github-actions-cache-server.js';
import {DEFAULT_TIMEOUT} from './util/rig-test.js';

const __filename = fileURLToPath(import.meta.url);
const __dirname = pathlib.dirname(__filename);
const repoRoot = pathlib.resolve(__dirname, '..', '..');

if (!x509.cryptoProvider.has('default')) {
  // On Node 18, the x509 library which selfsigned uses fails to find a crypto
  // provider because no default gets initialized (not sure why). Set a native
  // default.
  // TODO(aomarks) Do we really need to use TLS for this test?
  console.log('Setting x509 default provider to node:crypto.webcrypto');
  x509.cryptoProvider.set(crypto.webcrypto);
}

const SELF_SIGNED_CERT = await selfsigned.generate(
  [{name: 'commonName', value: 'localhost'}],
  // More recent versions of TLS require a larger minimum key size than the
  // default of this library (1024). Let's also upgrade from sha1 to sha256
  // while we're at it.
  {keySize: 2048, algorithm: 'sha256'},
);
const SELF_SIGNED_CERT_PATH = pathlib.resolve(
  repoRoot,
  'temp',
  'self-signed.cert',
);

// Long-lived server for the common cache tests registered via
// registerCommonCacheTests (which use rigTest internally and create their
// own rigs, so they need a stable server URL passed as env).
const commonAuthToken = String(Math.random()).slice(2);
const commonServer = new FakeGitHubActionsCacheServer(commonAuthToken, {
  cert: SELF_SIGNED_CERT.cert,
  key: SELF_SIGNED_CERT.private,
});
const commonActionsCacheUrl = await commonServer.listen();

let server: FakeGitHubActionsCacheServer;
let rig: WireitTestRig;

before(async () => {
  await fs.mkdir(pathlib.dirname(SELF_SIGNED_CERT_PATH), {recursive: true});
  await fs.writeFile(SELF_SIGNED_CERT_PATH, SELF_SIGNED_CERT.cert);
});

after(async () => {
  await commonServer.close();
});

beforeEach(async () => {
  // Set up the cache service for each test (as opposed to for the whole
  // suite) because we want fresh cache state for each test.
  const authToken = String(Math.random()).slice(2);
  server = new FakeGitHubActionsCacheServer(authToken, {
    cert: SELF_SIGNED_CERT.cert,
    key: SELF_SIGNED_CERT.private,
  });
  const actionsCacheUrl = await server.listen();
  rig = new WireitTestRig();
  rig.env = {
    ...rig.env,
    WIREIT_CACHE: 'github',
    ACTIONS_RESULTS_URL: actionsCacheUrl,
    ACTIONS_RUNTIME_TOKEN: authToken,
    RUNNER_TEMP: pathlib.join(rig.temp, 'github-cache-temp'),
    // Tell Node to trust our self-signed certificate for HTTPS.
    NODE_EXTRA_CA_CERTS: SELF_SIGNED_CERT_PATH,
  };
  await rig.setup();
});

afterEach(async () => {
  await Promise.all([server.close(), rig.cleanup()]);
});

registerCommonCacheTests((...args) => void test(...args), 'github', {
  WIREIT_CACHE: 'github',
  ACTIONS_RESULTS_URL: commonActionsCacheUrl,
  ACTIONS_RUNTIME_TOKEN: commonAuthToken,
  NODE_EXTRA_CA_CERTS: SELF_SIGNED_CERT_PATH,
});

void test('cache key affected by ImageOS environment variable', async () => {
  const cmdA = await rig.newCommand();
  await rig.write({
    'package.json': {
      scripts: {
        a: 'wireit',
      },
      wireit: {
        a: {
          command: cmdA.command,
          files: ['input'],
          output: ['output'],
        },
      },
    },
    input: 'v0',
  });

  // Initial run with input v0 and OS ubuntu18.
  {
    const exec = rig.exec('npm run a', {env: {ImageOS: 'ubuntu18'}});
    const inv = await cmdA.nextInvocation();
    await rig.write({output: 'v0'});
    inv.exit(0);
    const res = await exec.exit;
    assert.equal(res.code, 0);
    assert.equal(cmdA.numInvocations, 1);
    assert.equal(await rig.read('output'), 'v0');
  }

  // Input changed to v1. Run again.
  {
    await rig.write({input: 'v1'});
    const exec = rig.exec('npm run a', {env: {ImageOS: 'ubuntu18'}});
    const inv = await cmdA.nextInvocation();
    await rig.write({output: 'v1'});
    inv.exit(0);
    const res = await exec.exit;
    assert.equal(res.code, 0);
    assert.equal(cmdA.numInvocations, 2);
    assert.equal(await rig.read('output'), 'v1');
  }

  // Input changed back to v0, but OS is now ubuntu20. Output should not be
  // cached, because we changed OS.
  {
    await rig.write({input: 'v0'});
    const exec = rig.exec('npm run a', {env: {ImageOS: 'ubuntu20'}});
    const inv = await cmdA.nextInvocation();
    assert.ok(!(await rig.exists('output')));
    inv.exit(0);
    const res = await exec.exit;
    assert.equal(res.code, 0);
    assert.equal(cmdA.numInvocations, 3);
  }
});

void test('recovers from reservation race condition', async () => {
  const cmdA = await rig.newCommand();
  await rig.write({
    'package.json': {
      scripts: {
        a: 'wireit',
      },
      wireit: {
        a: {
          command: cmdA.command,
          files: [],
          // Set empty output so that Wireit doesn't try to serialize all of
          // our "npm run" commands. Note that we *do* cache empty output, so
          // this is still covering the important part of this test.
          output: [],
        },
      },
    },
  });

  // Start n Wireit processes for the same script at the same time.
  const n = 5;
  const execs = [];
  const invs = [];
  for (let i = 0; i < n; i++) {
    execs.push(rig.exec('npm run a'));
    invs.push(cmdA.nextInvocation());
  }

  // Wait for all script invocations to start.
  const started = await Promise.all(invs);

  // Have all scripts exit at approximately the same time. This will trigger
  // the race condition, because every script has already called "get" and saw
  // a cache miss, and will now all call "set" to try and reserve and save the
  // cache entry. But only one of them will get the reservation, the others
  // should just continue without error.
  for (const inv of started) {
    inv.exit(0);
  }

  // All Wireit processes should successfully exit, even if the race condition
  // occured.
  for (const exec of execs) {
    assert.equal((await exec.exit).code, 0);
  }
  assert.equal(cmdA.numInvocations, n);
  assert.deepEqual(server.metrics, {
    getCacheEntry: n,
    createCacheEntry: n,
    putBlobBlock: 1,
    putBlobBlockList: 1,
    finalizeCacheEntry: 1,
    getBlob: 0,
  } satisfies FakeGitHubActionsCacheServerMetrics);

  // Delete the ".wireit" folder so that the next run won't be considered
  // fresh, and the "output" file so that we can be sure it gets restored from
  // cache.
  await rig.delete('.wireit');

  // Do a final run to confirm that one of the scripts saved the cache.
  const exec = rig.exec('npm run a');
  assert.equal((await exec.exit).code, 0);
  assert.equal(cmdA.numInvocations, n);
  assert.deepEqual(server.metrics, {
    getCacheEntry: n + 1,
    createCacheEntry: n,
    putBlobBlock: 1,
    putBlobBlockList: 1,
    finalizeCacheEntry: 1,
    getBlob: 1,
  } satisfies FakeGitHubActionsCacheServerMetrics);
});

function assertSuccess(exitResult: ExitResult) {
  if (exitResult.code !== 0) {
    console.error(exitResult.stdout);
    console.error(exitResult.stderr);
    throw new Error(`Expected exit code 0, got ${exitResult.code}`);
  }
}

void test(`gracefully handles 409 conflict on set()`, async () => {
  const cmdA = await rig.newCommand();
  const cmdB = await rig.newCommand();
  await rig.write({
    'package.json': {
      scripts: {
        a: 'wireit',
        b: 'wireit',
      },
      wireit: {
        a: {
          command: cmdA.command,
          files: ['input'],
          output: [],
          dependencies: ['b'],
        },
        b: {
          command: cmdB.command,
          files: ['input'],
          output: [],
        },
      },
    },
    input: 'foo',
  });

  server.forceErrorOnNextRequest('createCacheEntry', 409);
  const exec = rig.exec('npm run a');
  (await cmdB.nextInvocation()).exit(0);
  (await cmdA.nextInvocation()).exit(0);
  assertSuccess(await exec.exit);
  assert.deepEqual(server.metrics, {
    // Both scripts should check for and then try to reserve a cache entry ...
    getCacheEntry: 2,
    createCacheEntry: 2,
    // ... but since the first one will fail to reserve with a 409, only the
    // second one will actually finish uploading its entry.
    putBlobBlock: 1,
    putBlobBlockList: 1,
    finalizeCacheEntry: 1,
    getBlob: 0,
  } satisfies FakeGitHubActionsCacheServerMetrics);
  assert.equal(cmdA.numInvocations, 1);
  assert.equal(cmdA.numInvocations, 1);
});

const randomInt = (minIncl: number, maxExcl: number) =>
  minIncl + Math.floor(Math.random() * (maxExcl - minIncl));

for (const code of [
  'ECONNRESET',
  (() => {
    while (true) {
      const status = randomInt(400, 500);
      if (status !== /* Conflict has special meaning (see above test) */ 409) {
        return status;
      }
    }
  })(),
  randomInt(500, 600),
] as const) {
  void test(`recovers from ${code} error within get()`, async () => {
    const cmdA = await rig.newCommand();
    const cmdB = await rig.newCommand();
    await rig.write({
      'package.json': {
        scripts: {
          a: 'wireit',
          b: 'wireit',
        },
        wireit: {
          a: {
            command: cmdA.command,
            files: ['input'],
            output: [],
            dependencies: ['b'],
          },
          b: {
            command: cmdB.command,
            files: ['input'],
            output: [],
          },
        },
      },
      input: 'foo',
    });

    server.forceErrorOnNextRequest('getCacheEntry', code);
    const exec = rig.exec('npm run a');
    (await cmdB.nextInvocation()).exit(0);
    (await cmdA.nextInvocation()).exit(0);
    assertSuccess(await exec.exit);
    assert.deepEqual(server.metrics, {
      getCacheEntry: 1,
      createCacheEntry: 0,
      putBlobBlock: 0,
      putBlobBlockList: 0,
      finalizeCacheEntry: 0,
      getBlob: 0,
    } satisfies FakeGitHubActionsCacheServerMetrics);
    assert.equal(cmdA.numInvocations, 1);
    assert.equal(cmdA.numInvocations, 1);
  });

  void test(`recovers from ${code} error within set()`, async () => {
    await rig.write({
      'package.json': {
        scripts: {
          a: 'wireit',
          b: 'wireit',
        },
        wireit: {
          a: {
            command: 'true',
            files: ['input'],
            output: [],
            dependencies: ['b'],
          },
          b: {
            command: 'true',
            files: ['input'],
            output: [],
          },
        },
      },
    });

    // Do a successful run to populate the cache.
    await rig.write('input', 'cached');
    assertSuccess(await rig.exec('npm run a').exit);
    assert.deepEqual(server.metrics, {
      getCacheEntry: 2,
      createCacheEntry: 2,
      putBlobBlock: 2,
      putBlobBlockList: 2,
      finalizeCacheEntry: 2,
      getBlob: 0,
    } satisfies FakeGitHubActionsCacheServerMetrics);

    // Check API
    server.forceErrorOnNextRequest('getCacheEntry', code);
    server.resetMetrics();
    await rig.write('input', '0');
    assertSuccess(await rig.exec('npm run a').exit);
    assert.deepEqual(server.metrics, {
      // Note that because we turn off GitHub Actions Caching after the first
      // rate limit error, "b" fails and then "a" skips, so this count is 1
      // instead of 2.
      getCacheEntry: 1,
      createCacheEntry: 0,
      putBlobBlock: 0,
      putBlobBlockList: 0,
      finalizeCacheEntry: 0,
      getBlob: 0,
    } satisfies FakeGitHubActionsCacheServerMetrics);

    // Reserve API
    server.forceErrorOnNextRequest('createCacheEntry', code);
    server.resetMetrics();
    await rig.write('input', '1');
    assertSuccess(await rig.exec('npm run a').exit);
    assert.deepEqual(server.metrics, {
      getCacheEntry: 1,
      createCacheEntry: 1,
      putBlobBlock: 0,
      putBlobBlockList: 0,
      finalizeCacheEntry: 0,
      getBlob: 0,
    } satisfies FakeGitHubActionsCacheServerMetrics);

    // Upload API
    server.forceErrorOnNextRequest('putBlobBlock', code);
    server.resetMetrics();
    await rig.write('input', '2');
    assertSuccess(await rig.exec('npm run a').exit);
    assert.deepEqual(server.metrics, {
      getCacheEntry: 1,
      createCacheEntry: 1,
      putBlobBlock: 1,
      putBlobBlockList: 0,
      finalizeCacheEntry: 0,
      getBlob: 0,
    } satisfies FakeGitHubActionsCacheServerMetrics);

    // Commit API
    server.forceErrorOnNextRequest('finalizeCacheEntry', code);
    server.resetMetrics();
    await rig.write('input', '3');
    assertSuccess(await rig.exec('npm run a').exit);
    assert.deepEqual(server.metrics, {
      getCacheEntry: 1,
      createCacheEntry: 1,
      putBlobBlock: 1,
      putBlobBlockList: 1,
      finalizeCacheEntry: 1,
      getBlob: 0,
    } satisfies FakeGitHubActionsCacheServerMetrics);

    // Download API
    server.forceErrorOnNextRequest('getBlob', code);
    server.resetMetrics();
    await rig.write('input', 'cached');
    assertSuccess(await rig.exec('npm run a').exit);
    assert.deepEqual(server.metrics, {
      getCacheEntry: 2,
      createCacheEntry: 0,
      putBlobBlock: 0,
      putBlobBlockList: 0,
      finalizeCacheEntry: 0,
      getBlob: 2,
    } satisfies FakeGitHubActionsCacheServerMetrics);
  });
}

void test(
  'uploads large tarball in multiple chunks',
  {timeout: Math.max(DEFAULT_TIMEOUT, 15_000)},
  async () => {
    const cmdA = await rig.newCommand();

    await rig.write({
      'package.json': {
        scripts: {
          a: 'wireit',
        },
        wireit: {
          a: {
            command: cmdA.command,
            files: ['input'],
            output: ['output'],
          },
        },
      },
    });

    // Generate a random file which is big enough to exceed the maximum chunk
    // size, so that it gets split into 2 separate upload requests.
    //
    // The maximum chunk size is defined here:
    // https://github.com/actions/toolkit/blob/500d0b42fee2552ae9eeb5933091fe2fbf14e72d/packages/cache/src/options.ts#L59
    //
    // This needs to be actually random data, not just arbitrary, because the
    // tarball will be compressed, and we need a poor compression ratio in order
    // to hit our target size.
    const MB = 1024 * 1024;
    const maxChunkBytes = 32 * MB;
    const compressionHeadroomBytes = 8 * MB; // Found experimentally.
    const totalBytes = maxChunkBytes + compressionHeadroomBytes;
    const fileContent = crypto.randomBytes(totalBytes).toString();

    // On the initial run a large file is created and should be cached.
    {
      await rig.write('input', 'v0');
      server.resetMetrics();

      const exec = rig.exec('npm run a');
      const inv = await cmdA.nextInvocation();
      await rig.write('output', fileContent);
      inv.exit(0);

      // Note here is when we are creating the compressed tarball, which is the
      // slowest part of this test.

      assert.equal((await exec.exit).code, 0);
      assert.equal(cmdA.numInvocations, 1);
      assert.deepEqual(server.metrics, {
        getCacheEntry: 1,
        createCacheEntry: 1,
        // Since we had a file that was larger than the maximum chunk size, we
        // should have 2 upload requests.
        putBlobBlock: 2,
        putBlobBlockList: 1,
        finalizeCacheEntry: 1,
        getBlob: 0,
      } satisfies FakeGitHubActionsCacheServerMetrics);
    }

    // Invalidate cache by changing input.
    {
      await rig.write('input', 'v1');
      server.resetMetrics();

      const exec = rig.exec('npm run a');
      const inv = await cmdA.nextInvocation();
      assert.ok(!(await rig.exists('output')));
      inv.exit(0);

      assert.equal((await exec.exit).code, 0);
      assert.equal(cmdA.numInvocations, 2);
      assert.deepEqual(server.metrics, {
        getCacheEntry: 1,
        createCacheEntry: 1,
        putBlobBlock: 1,
        putBlobBlockList: 1,
        finalizeCacheEntry: 1,
        getBlob: 0,
      } satisfies FakeGitHubActionsCacheServerMetrics);
    }

    // Change input back to v0. The large file should be restored from cache.
    {
      await rig.write('input', 'v0');
      server.resetMetrics();

      const exec = rig.exec('npm run a');

      assert.equal((await exec.exit).code, 0);
      assert.equal(cmdA.numInvocations, 2);
      assert.deepEqual(server.metrics, {
        getCacheEntry: 1,
        createCacheEntry: 0,
        putBlobBlock: 0,
        putBlobBlockList: 0,
        finalizeCacheEntry: 0,
        getBlob: 1,
      } satisfies FakeGitHubActionsCacheServerMetrics);
      assert.equal(await rig.read('output'), fileContent);
    }
  },
);
