import * as fs from "fs";
import path from "path";

import * as actionsCache from "@actions/cache";
import * as glob from "@actions/glob";
import test from "ava";
import * as sinon from "sinon";

import { cacheKeyHashLength } from "./caching-utils";
import * as cachingUtils from "./caching-utils";
import { createStubCodeQL } from "./codeql";
import {
  CacheConfig,
  checkHashPatterns,
  getCsharpHashPatterns,
  getFeaturePrefix,
  makePatternCheck,
  internal,
  CSHARP_BASE_PATTERNS,
  CSHARP_EXTRA_PATTERNS,
  downloadDependencyCaches,
  CacheHitKind,
  cacheKey,
  getCsharpDependencyDirs,
  getCsharpTempDependencyDir,
  uploadDependencyCaches,
  CacheStoreResult,
} from "./dependency-caching";
import { Feature } from "./feature-flags";
import { BuiltInLanguage } from "./languages";
import {
  setupTests,
  createFeatures,
  getRecordingLogger,
  checkExpectedLogMessages,
  LoggedMessage,
  createTestConfig,
} from "./testing-utils";
import { withTmpDir } from "./util";

setupTests(test);

function makeAbsolutePatterns(tmpDir: string, patterns: string[]): string[] {
  return patterns.map((pattern) => path.join(tmpDir, pattern));
}

test.serial(
  "getCsharpDependencyDirs - does not include BMN dir if FF is disabled",
  async (t) => {
    await withTmpDir(async (tmpDir) => {
      process.env["RUNNER_TEMP"] = tmpDir;
      const codeql = createStubCodeQL({});
      const features = createFeatures([]);

      const results = await getCsharpDependencyDirs(codeql, features);
      t.false(results.includes(getCsharpTempDependencyDir()));
    });
  },
);

test.serial(
  "getCsharpDependencyDirs - includes BMN dir if FF is enabled",
  async (t) => {
    await withTmpDir(async (tmpDir) => {
      process.env["RUNNER_TEMP"] = tmpDir;
      const codeql = createStubCodeQL({});
      const features = createFeatures([Feature.CsharpCacheBuildModeNone]);

      const results = await getCsharpDependencyDirs(codeql, features);
      t.assert(results.includes(getCsharpTempDependencyDir()));
    });
  },
);

test("makePatternCheck - returns undefined if no patterns match", async (t) => {
  await withTmpDir(async (tmpDir) => {
    fs.writeFileSync(path.join(tmpDir, "test.java"), "");
    const result = await makePatternCheck(
      makeAbsolutePatterns(tmpDir, ["**/*.cs"]),
    );
    t.is(result, undefined);
  });
});

test("makePatternCheck - returns all patterns if any pattern matches", async (t) => {
  await withTmpDir(async (tmpDir) => {
    fs.writeFileSync(path.join(tmpDir, "test.java"), "");
    const patterns = makeAbsolutePatterns(tmpDir, ["**/*.cs", "**/*.java"]);
    const result = await makePatternCheck(patterns);
    t.deepEqual(result, patterns);
  });
});

test.serial(
  "getCsharpHashPatterns - returns base patterns if any pattern matches",
  async (t) => {
    const codeql = createStubCodeQL({});
    const features = createFeatures([]);
    const makePatternCheckStub = sinon.stub(internal, "makePatternCheck");

    makePatternCheckStub
      .withArgs(CSHARP_BASE_PATTERNS)
      .resolves(CSHARP_BASE_PATTERNS);
    makePatternCheckStub.withArgs(CSHARP_EXTRA_PATTERNS).rejects();

    await t.notThrowsAsync(async () => {
      const result = await getCsharpHashPatterns(codeql, features);
      t.deepEqual(result, CSHARP_BASE_PATTERNS);
    });
  },
);

test.serial(
  "getCsharpHashPatterns - returns base patterns if any base pattern matches and CsharpNewCacheKey is enabled",
  async (t) => {
    const codeql = createStubCodeQL({});
    const features = createFeatures([Feature.CsharpNewCacheKey]);
    const makePatternCheckStub = sinon.stub(internal, "makePatternCheck");

    makePatternCheckStub
      .withArgs(CSHARP_BASE_PATTERNS)
      .resolves(CSHARP_BASE_PATTERNS);
    makePatternCheckStub
      .withArgs(CSHARP_EXTRA_PATTERNS)
      .resolves(CSHARP_EXTRA_PATTERNS);

    await t.notThrowsAsync(async () => {
      const result = await getCsharpHashPatterns(codeql, features);
      t.deepEqual(result, CSHARP_BASE_PATTERNS);
    });
  },
);

test.serial(
  "getCsharpHashPatterns - returns extra patterns if any extra pattern matches and CsharpNewCacheKey is enabled",
  async (t) => {
    const codeql = createStubCodeQL({});
    const features = createFeatures([Feature.CsharpNewCacheKey]);
    const makePatternCheckStub = sinon.stub(internal, "makePatternCheck");

    makePatternCheckStub.withArgs(CSHARP_BASE_PATTERNS).resolves(undefined);
    makePatternCheckStub
      .withArgs(CSHARP_EXTRA_PATTERNS)
      .resolves(CSHARP_EXTRA_PATTERNS);

    await t.notThrowsAsync(async () => {
      const result = await getCsharpHashPatterns(codeql, features);
      t.deepEqual(result, CSHARP_EXTRA_PATTERNS);
    });
  },
);

test.serial(
  "getCsharpHashPatterns - returns undefined if neither base nor extra patterns match",
  async (t) => {
    const codeql = createStubCodeQL({});
    const features = createFeatures([Feature.CsharpNewCacheKey]);
    const makePatternCheckStub = sinon.stub(internal, "makePatternCheck");

    makePatternCheckStub.withArgs(CSHARP_BASE_PATTERNS).resolves(undefined);
    makePatternCheckStub.withArgs(CSHARP_EXTRA_PATTERNS).resolves(undefined);

    await t.notThrowsAsync(async () => {
      const result = await getCsharpHashPatterns(codeql, features);
      t.deepEqual(result, undefined);
    });
  },
);

test("checkHashPatterns - logs when no patterns match", async (t) => {
  const codeql = createStubCodeQL({});
  const features = createFeatures([]);
  const messages: LoggedMessage[] = [];
  const config: CacheConfig = {
    getDependencyPaths: async () => [],
    getHashPatterns: async () => undefined,
  };

  const result = await checkHashPatterns(
    codeql,
    features,
    BuiltInLanguage.csharp,
    config,
    "download",
    getRecordingLogger(messages),
  );

  t.is(result, undefined);
  checkExpectedLogMessages(t, messages, [
    "Skipping download of dependency cache",
  ]);
});

test("checkHashPatterns - returns patterns when patterns match", async (t) => {
  await withTmpDir(async (tmpDir) => {
    const codeql = createStubCodeQL({});
    const features = createFeatures([]);
    const messages: LoggedMessage[] = [];
    const patterns = makeAbsolutePatterns(tmpDir, ["**/*.cs", "**/*.java"]);

    fs.writeFileSync(path.join(tmpDir, "test.java"), "");

    const config: CacheConfig = {
      getDependencyPaths: async () => [],
      getHashPatterns: async () => makePatternCheck(patterns),
    };

    const result = await checkHashPatterns(
      codeql,
      features,
      BuiltInLanguage.csharp,
      config,
      "upload",
      getRecordingLogger(messages),
    );

    t.deepEqual(result, patterns);
    t.deepEqual(messages, []);
  });
});

type RestoreCacheFunc = (
  paths: string[],
  primaryKey: string,
  restoreKeys: string[] | undefined,
) => Promise<string | undefined>;

/**
 * Constructs a function that `actionsCache.restoreCache` can be stubbed with.
 *
 * @param mockCacheKeys The keys of caches that we want to exist in the Actions cache.
 *
 * @returns Returns a function that `actionsCache.restoreCache` can be stubbed with.
 */
function makeMockCacheCheck(mockCacheKeys: string[]): RestoreCacheFunc {
  return async (
    _paths: string[],
    primaryKey: string,
    restoreKeys: string[] | undefined,
  ) => {
    // The behaviour here mirrors what the real `restoreCache` would do:
    // - Starting with the primary restore key, check all caches for a match:
    //   even for the primary restore key, this only has to be a prefix match.
    // - If the primary restore key doesn't prefix-match any cache, then proceed
    //   in the same way for each restore key in turn.
    for (const restoreKey of [primaryKey, ...(restoreKeys || [])]) {
      for (const mockCacheKey of mockCacheKeys) {
        if (mockCacheKey.startsWith(restoreKey)) {
          return mockCacheKey;
        }
      }
    }
    // Only if no restore key matches any cache key prefix, there is no matching
    // cache and we return `undefined`.
    return undefined;
  };
}

test.serial(
  "downloadDependencyCaches - does not restore caches with feature keys if no features are enabled",
  async (t) => {
    process.env["RUNNER_OS"] = "Linux";

    const codeql = createStubCodeQL({});
    const messages: LoggedMessage[] = [];
    const logger = getRecordingLogger(messages);

    sinon.stub(glob, "hashFiles").resolves("abcdef");

    const keyWithFeature = await cacheKey(
      codeql,
      createFeatures([Feature.CsharpNewCacheKey]),
      BuiltInLanguage.csharp,
      // Patterns don't matter here because we have stubbed `hashFiles` to always return a specific hash above.
      [],
    );

    const restoreCacheStub = sinon
      .stub(actionsCache, "restoreCache")
      .callsFake(makeMockCacheCheck([keyWithFeature]));

    const makePatternCheckStub = sinon.stub(internal, "makePatternCheck");
    makePatternCheckStub
      .withArgs(CSHARP_BASE_PATTERNS)
      .resolves(CSHARP_BASE_PATTERNS);
    makePatternCheckStub.withArgs(CSHARP_EXTRA_PATTERNS).resolves(undefined);

    const result = await downloadDependencyCaches(
      codeql,
      createFeatures([]),
      [BuiltInLanguage.csharp],
      logger,
    );
    const statusReport = result.statusReport;
    t.is(statusReport.length, 1);
    t.is(statusReport[0].language, BuiltInLanguage.csharp);
    t.is(statusReport[0].hit_kind, CacheHitKind.Miss);
    t.deepEqual(result.restoredKeys, []);
    t.assert(restoreCacheStub.calledOnce);
  },
);

test.serial(
  "downloadDependencyCaches - restores caches with feature keys if features are enabled",
  async (t) => {
    process.env["RUNNER_OS"] = "Linux";

    const codeql = createStubCodeQL({});
    const messages: LoggedMessage[] = [];
    const logger = getRecordingLogger(messages);
    const features = createFeatures([Feature.CsharpNewCacheKey]);

    const mockHash = "abcdef";
    sinon.stub(glob, "hashFiles").resolves(mockHash);

    const keyWithFeature = await cacheKey(
      codeql,
      features,
      BuiltInLanguage.csharp,
      // Patterns don't matter here because we have stubbed `hashFiles` to always return a specific hash above.
      [],
    );

    const restoreCacheStub = sinon
      .stub(actionsCache, "restoreCache")
      .callsFake(makeMockCacheCheck([keyWithFeature]));

    const makePatternCheckStub = sinon.stub(internal, "makePatternCheck");
    makePatternCheckStub
      .withArgs(CSHARP_BASE_PATTERNS)
      .resolves(CSHARP_BASE_PATTERNS);
    makePatternCheckStub.withArgs(CSHARP_EXTRA_PATTERNS).resolves(undefined);

    const result = await downloadDependencyCaches(
      codeql,
      features,
      [BuiltInLanguage.csharp],
      logger,
    );

    // Check that the status report for telemetry indicates that one cache was restored with an exact match.
    const statusReport = result.statusReport;
    t.is(statusReport.length, 1);
    t.is(statusReport[0].language, BuiltInLanguage.csharp);
    t.is(statusReport[0].hit_kind, CacheHitKind.Exact);

    // Check that the restored key has been returned.
    const restoredKeys = result.restoredKeys;
    t.is(restoredKeys.length, 1);
    t.assert(
      restoredKeys[0].endsWith(mockHash),
      "Expected restored key to end with hash returned by `hashFiles`",
    );

    // `restoreCache` should have been called exactly once.
    t.assert(restoreCacheStub.calledOnce);
  },
);

test.serial(
  "downloadDependencyCaches - restores caches with feature keys if features are enabled for partial matches",
  async (t) => {
    process.env["RUNNER_OS"] = "Linux";

    const codeql = createStubCodeQL({});
    const messages: LoggedMessage[] = [];
    const logger = getRecordingLogger(messages);
    const features = createFeatures([Feature.CsharpNewCacheKey]);

    // We expect two calls to `hashFiles`: the first by the call to `cacheKey` below,
    // and the second by `downloadDependencyCaches`. We use the result of the first
    // call as part of the cache key that identifies a mock, existing cache. The result
    // of the second call is for the primary restore key, which we don't want to match
    // the first key so that we can test the restore keys logic.
    const restoredHash = "abcdef";
    const hashFilesStub = sinon.stub(glob, "hashFiles");
    hashFilesStub.onFirstCall().resolves(restoredHash);
    hashFilesStub.onSecondCall().resolves("123456");

    const keyWithFeature = await cacheKey(
      codeql,
      features,
      BuiltInLanguage.csharp,
      // Patterns don't matter here because we have stubbed `hashFiles` to always return a specific hash above.
      [],
    );

    const restoreCacheStub = sinon
      .stub(actionsCache, "restoreCache")
      .callsFake(makeMockCacheCheck([keyWithFeature]));

    const makePatternCheckStub = sinon.stub(internal, "makePatternCheck");
    makePatternCheckStub
      .withArgs(CSHARP_BASE_PATTERNS)
      .resolves(CSHARP_BASE_PATTERNS);
    makePatternCheckStub.withArgs(CSHARP_EXTRA_PATTERNS).resolves(undefined);

    const result = await downloadDependencyCaches(
      codeql,
      features,
      [BuiltInLanguage.csharp],
      logger,
    );

    // Check that the status report for telemetry indicates that one cache was restored with a partial match.
    const statusReport = result.statusReport;
    t.is(statusReport.length, 1);
    t.is(statusReport[0].language, BuiltInLanguage.csharp);
    t.is(statusReport[0].hit_kind, CacheHitKind.Partial);

    // Check that the restored key has been returned.
    const restoredKeys = result.restoredKeys;
    t.is(restoredKeys.length, 1);
    t.assert(
      restoredKeys[0].endsWith(restoredHash),
      "Expected restored key to end with hash returned by `hashFiles`",
    );

    t.assert(restoreCacheStub.calledOnce);
  },
);

test("uploadDependencyCaches - skips upload for a language with no cache config", async (t) => {
  const codeql = createStubCodeQL({});
  const messages: LoggedMessage[] = [];
  const logger = getRecordingLogger(messages);
  const features = createFeatures([]);
  const config = createTestConfig({
    languages: [BuiltInLanguage.actions],
  });

  const result = await uploadDependencyCaches(codeql, features, config, logger);
  t.is(result.length, 0);
  checkExpectedLogMessages(t, messages, [
    "Skipping upload of dependency cache for actions",
  ]);
});

test.serial(
  "uploadDependencyCaches - skips upload if no files for the hash exist",
  async (t) => {
    const codeql = createStubCodeQL({});
    const messages: LoggedMessage[] = [];
    const logger = getRecordingLogger(messages);
    const features = createFeatures([]);
    const config = createTestConfig({
      languages: [BuiltInLanguage.go],
    });

    const makePatternCheckStub = sinon.stub(internal, "makePatternCheck");
    makePatternCheckStub.resolves(undefined);

    const result = await uploadDependencyCaches(
      codeql,
      features,
      config,
      logger,
    );
    t.is(result.length, 1);
    t.is(result[0].language, BuiltInLanguage.go);
    t.is(result[0].result, CacheStoreResult.NoHash);
  },
);

test.serial(
  "uploadDependencyCaches - skips upload if we know the cache already exists",
  async (t) => {
    process.env["RUNNER_OS"] = "Linux";

    const codeql = createStubCodeQL({});
    const messages: LoggedMessage[] = [];
    const logger = getRecordingLogger(messages);
    const features = createFeatures([]);

    const mockHash = "abcdef";
    sinon.stub(glob, "hashFiles").resolves(mockHash);

    const makePatternCheckStub = sinon.stub(internal, "makePatternCheck");
    makePatternCheckStub
      .withArgs(CSHARP_BASE_PATTERNS)
      .resolves(CSHARP_BASE_PATTERNS);

    const primaryCacheKey = await cacheKey(
      codeql,
      features,
      BuiltInLanguage.csharp,
      CSHARP_BASE_PATTERNS,
    );

    const config = createTestConfig({
      languages: [BuiltInLanguage.csharp],
      dependencyCachingRestoredKeys: [primaryCacheKey],
    });

    const result = await uploadDependencyCaches(
      codeql,
      features,
      config,
      logger,
    );
    t.is(result.length, 1);
    t.is(result[0].language, BuiltInLanguage.csharp);
    t.is(result[0].result, CacheStoreResult.Duplicate);
  },
);

test.serial(
  "uploadDependencyCaches - skips upload if cache size is 0",
  async (t) => {
    process.env["RUNNER_OS"] = "Linux";

    const codeql = createStubCodeQL({});
    const messages: LoggedMessage[] = [];
    const logger = getRecordingLogger(messages);
    const features = createFeatures([]);

    const mockHash = "abcdef";
    sinon.stub(glob, "hashFiles").resolves(mockHash);

    const makePatternCheckStub = sinon.stub(internal, "makePatternCheck");
    makePatternCheckStub
      .withArgs(CSHARP_BASE_PATTERNS)
      .resolves(CSHARP_BASE_PATTERNS);

    sinon.stub(cachingUtils, "getTotalCacheSize").resolves(0);

    const config = createTestConfig({
      languages: [BuiltInLanguage.csharp],
    });

    const result = await uploadDependencyCaches(
      codeql,
      features,
      config,
      logger,
    );
    t.is(result.length, 1);
    t.is(result[0].language, BuiltInLanguage.csharp);
    t.is(result[0].result, CacheStoreResult.Empty);

    checkExpectedLogMessages(t, messages, [
      "Skipping upload of dependency cache",
    ]);
  },
);

test.serial(
  "uploadDependencyCaches - uploads caches when all requirements are met",
  async (t) => {
    process.env["RUNNER_OS"] = "Linux";

    const codeql = createStubCodeQL({});
    const messages: LoggedMessage[] = [];
    const logger = getRecordingLogger(messages);
    const features = createFeatures([]);

    const mockHash = "abcdef";
    sinon.stub(glob, "hashFiles").resolves(mockHash);

    const makePatternCheckStub = sinon.stub(internal, "makePatternCheck");
    makePatternCheckStub
      .withArgs(CSHARP_BASE_PATTERNS)
      .resolves(CSHARP_BASE_PATTERNS);

    sinon.stub(cachingUtils, "getTotalCacheSize").resolves(1024);
    sinon.stub(actionsCache, "saveCache").resolves();

    const config = createTestConfig({
      languages: [BuiltInLanguage.csharp],
    });

    const result = await uploadDependencyCaches(
      codeql,
      features,
      config,
      logger,
    );
    t.is(result.length, 1);
    t.is(result[0].language, BuiltInLanguage.csharp);
    t.is(result[0].result, CacheStoreResult.Stored);
    t.is(result[0].upload_size_bytes, 1024);

    checkExpectedLogMessages(t, messages, ["Uploading cache of size"]);
  },
);

test.serial(
  "uploadDependencyCaches - catches `ReserveCacheError` exceptions",
  async (t) => {
    process.env["RUNNER_OS"] = "Linux";

    const codeql = createStubCodeQL({});
    const messages: LoggedMessage[] = [];
    const logger = getRecordingLogger(messages);
    const features = createFeatures([]);

    const mockHash = "abcdef";
    sinon.stub(glob, "hashFiles").resolves(mockHash);

    const makePatternCheckStub = sinon.stub(internal, "makePatternCheck");
    makePatternCheckStub
      .withArgs(CSHARP_BASE_PATTERNS)
      .resolves(CSHARP_BASE_PATTERNS);

    sinon.stub(cachingUtils, "getTotalCacheSize").resolves(1024);
    sinon
      .stub(actionsCache, "saveCache")
      .throws(new actionsCache.ReserveCacheError("Already in use"));

    const config = createTestConfig({
      languages: [BuiltInLanguage.csharp],
    });

    await t.notThrowsAsync(async () => {
      const result = await uploadDependencyCaches(
        codeql,
        features,
        config,
        logger,
      );
      t.is(result.length, 1);
      t.is(result[0].language, BuiltInLanguage.csharp);
      t.is(result[0].result, CacheStoreResult.Duplicate);

      checkExpectedLogMessages(t, messages, ["Not uploading cache for"]);
    });
  },
);

test.serial("uploadDependencyCaches - throws other exceptions", async (t) => {
  process.env["RUNNER_OS"] = "Linux";

  const codeql = createStubCodeQL({});
  const messages: LoggedMessage[] = [];
  const logger = getRecordingLogger(messages);
  const features = createFeatures([]);

  const mockHash = "abcdef";
  sinon.stub(glob, "hashFiles").resolves(mockHash);

  const makePatternCheckStub = sinon.stub(internal, "makePatternCheck");
  makePatternCheckStub
    .withArgs(CSHARP_BASE_PATTERNS)
    .resolves(CSHARP_BASE_PATTERNS);

  sinon.stub(cachingUtils, "getTotalCacheSize").resolves(1024);
  sinon.stub(actionsCache, "saveCache").throws();

  const config = createTestConfig({
    languages: [BuiltInLanguage.csharp],
  });

  await t.throwsAsync(async () => {
    await uploadDependencyCaches(codeql, features, config, logger);
  });
});

test("getFeaturePrefix - returns empty string if no features are enabled", async (t) => {
  const codeql = createStubCodeQL({});
  const features = createFeatures([]);

  for (const knownLanguage of Object.values(BuiltInLanguage)) {
    const result = await getFeaturePrefix(codeql, features, knownLanguage);
    t.deepEqual(result, "", `Expected no feature prefix for ${knownLanguage}`);
  }
});

test("getFeaturePrefix - C# - returns prefix if CsharpNewCacheKey is enabled", async (t) => {
  const codeql = createStubCodeQL({});
  const features = createFeatures([Feature.CsharpNewCacheKey]);

  const result = await getFeaturePrefix(
    codeql,
    features,
    BuiltInLanguage.csharp,
  );
  t.notDeepEqual(result, "");
  t.assert(result.endsWith("-"));
  // Check the length of the prefix, which should correspond to `cacheKeyHashLength` + 1 for the trailing `-`.
  t.is(result.length, cacheKeyHashLength + 1);
});

test("getFeaturePrefix - non-C# - returns '' if CsharpNewCacheKey is enabled", async (t) => {
  const codeql = createStubCodeQL({});
  const features = createFeatures([Feature.CsharpNewCacheKey]);

  for (const knownLanguage of Object.values(BuiltInLanguage)) {
    // Skip C# since we expect a result for it, which is tested in the previous test.
    if (knownLanguage === BuiltInLanguage.csharp) {
      continue;
    }
    const result = await getFeaturePrefix(codeql, features, knownLanguage);
    t.deepEqual(result, "", `Expected no feature prefix for ${knownLanguage}`);
  }
});

test("getFeaturePrefix - C# - returns prefix if CsharpCacheBuildModeNone is enabled", async (t) => {
  const codeql = createStubCodeQL({});
  const features = createFeatures([Feature.CsharpCacheBuildModeNone]);

  const result = await getFeaturePrefix(
    codeql,
    features,
    BuiltInLanguage.csharp,
  );
  t.notDeepEqual(result, "");
  t.assert(result.endsWith("-"));
  // Check the length of the prefix, which should correspond to `cacheKeyHashLength` + 1 for the trailing `-`.
  t.is(result.length, cacheKeyHashLength + 1);
});

test("getFeaturePrefix - non-C# - returns '' if CsharpCacheBuildModeNone is enabled", async (t) => {
  const codeql = createStubCodeQL({});
  const features = createFeatures([Feature.CsharpCacheBuildModeNone]);

  for (const knownLanguage of Object.values(BuiltInLanguage)) {
    // Skip C# since we expect a result for it, which is tested in the previous test.
    if (knownLanguage === BuiltInLanguage.csharp) {
      continue;
    }
    const result = await getFeaturePrefix(codeql, features, knownLanguage);
    t.deepEqual(result, "", `Expected no feature prefix for ${knownLanguage}`);
  }
});
