import * as path from "path";

import * as core from "@actions/core";
import * as toolcache from "@actions/tool-cache";

import {
  getApiClient,
  getApiDetails,
  getAuthorizationHeaderFor,
  getGitHubVersion,
} from "./api-client";
import * as artifactScanner from "./artifact-scanner";
import { Config } from "./config-utils";
import * as defaults from "./defaults.json";
import {
  CodeQLDefaultVersionInfo,
  Feature,
  FeatureEnablement,
} from "./feature-flags";
import * as json from "./json";
import { BuiltInLanguage } from "./languages";
import { Logger } from "./logging";
import {
  Address,
  Registry,
  Credential,
  hasToken,
  hasUsernameAndPassword,
  hasUsername,
  RawCredential,
} from "./start-proxy/types";
import { getAuthConfig } from "./start-proxy/validation";
import {
  ActionName,
  createStatusReportBase,
  getActionsStatus,
  sendStatusReport,
  StatusReportBase,
} from "./status-report";
import * as util from "./util";
import { ConfigurationError, getErrorMessage, isDefined } from "./util";

export * from "./start-proxy/types";

/**
 * Enumerates specific error types for which we have corresponding error messages that
 * are safe to include in status reports.
 */
export enum StartProxyErrorType {
  DownloadFailed,
  ExtractionFailed,
  CacheFailed,
}

/**
 * @returns The error message corresponding to the error type.
 */
export function getStartProxyErrorMessage(
  errorType: StartProxyErrorType,
): string {
  switch (errorType) {
    case StartProxyErrorType.DownloadFailed:
      return "Failed to download proxy archive.";
    case StartProxyErrorType.ExtractionFailed:
      return "Failed to extract proxy archive.";
    case StartProxyErrorType.CacheFailed:
      return "Failed to add proxy to toolcache";
  }
}

/**
 * We want to avoid accidentally leaking secrets that may be contained in exception
 * messages in the `start-proxy` action. Consequently, we don't report the messages
 * of arbitrary exceptions. This type of error ensures that the message is one from
 * `StartProxyErrorType` and therefore safe to include in a status report.
 */
export class StartProxyError extends Error {
  public readonly errorType: StartProxyErrorType;

  constructor(errorType: StartProxyErrorType) {
    super();
    this.errorType = errorType;
  }
}

interface StartProxyStatus extends StatusReportBase {
  // A comma-separated list of registry types which are configured for CodeQL.
  // This only includes registry types we support, not all that are configured.
  registry_types: string;
}

/**
 * Sends a status report for the `start-proxy` action indicating a successful outcome.
 *
 * @param startedAt When the action was started.
 * @param config The configuration used.
 * @param registry_types The types of registries that are configured.
 * @param logger The logger to use.
 */
export async function sendSuccessStatusReport(
  startedAt: Date,
  config: Partial<Config>,
  registry_types: string[],
  logger: Logger,
) {
  const statusReportBase = await createStatusReportBase(
    ActionName.StartProxy,
    "success",
    startedAt,
    config,
    await util.checkDiskUsage(logger),
    logger,
  );
  if (statusReportBase !== undefined) {
    const statusReport: StartProxyStatus = {
      ...statusReportBase,
      registry_types: registry_types.join(","),
    };
    await sendStatusReport(statusReport);
  }
}

/**
 * Returns an error message for `error` that can safely be reported in a status report,
 * i.e. that does not contain sensitive information.
 *
 * @param error The error for which to get an error message.
 */
export function getSafeErrorMessage(error: Error): string {
  // If the error is a `StartProxyError`, resolve the error type to the corresponding
  // error message.
  if (error instanceof StartProxyError) {
    return getStartProxyErrorMessage(error.errorType);
  }

  // Otherwise, omit the actual error message.
  return `Error from start-proxy Action omitted (${error.constructor.name}).`;
}

/**
 * Sends a status report for the `start-proxy` action indicating a failure.
 *
 * @param logger The logger to use.
 * @param startedAt When the action was started.
 * @param language The language provided as input, if any.
 * @param unwrappedError The exception that was thrown.
 */
export async function sendFailedStatusReport(
  logger: Logger,
  startedAt: Date,
  language: BuiltInLanguage | undefined,
  unwrappedError: unknown,
) {
  const error = util.wrapError(unwrappedError);
  core.setFailed(`start-proxy action failed: ${error.message}`);

  // To avoid the possibility of leaking sensitive information into the telemetry,
  // we don't include arbitrary error messages. Instead, `getSafeErrorMessage` will
  // return a generic message that includes the type of the error, unless it can decide
  // that the message is safe to include.
  const statusReportMessage = getSafeErrorMessage(error);
  const errorStatusReportBase = await createStatusReportBase(
    ActionName.StartProxy,
    getActionsStatus(error),
    startedAt,
    {
      languages: language === undefined ? undefined : [language],
    },
    await util.checkDiskUsage(logger),
    logger,
    statusReportMessage,
  );
  if (errorStatusReportBase !== undefined) {
    await sendStatusReport(errorStatusReportBase);
  }
}

export const UPDATEJOB_PROXY = "update-job-proxy";
export const UPDATEJOB_PROXY_VERSION = "v2.0.20250624110901";
const UPDATEJOB_PROXY_URL_PREFIX =
  "https://github.com/github/codeql-action/releases/download/codeql-bundle-v2.22.0/";

function isPAT(value: string) {
  return artifactScanner.isAuthToken(value, [
    artifactScanner.GITHUB_PAT_CLASSIC_PATTERN,
    artifactScanner.GITHUB_PAT_FINE_GRAINED_PATTERN,
  ]);
}

type RegistryMapping = Partial<Record<BuiltInLanguage, string[]>>;

const LANGUAGE_TO_REGISTRY_TYPE: RegistryMapping = {
  java: ["maven_repository"],
  csharp: ["nuget_feed"],
  javascript: ["npm_registry"],
  python: ["python_index"],
  ruby: ["rubygems_server"],
  rust: ["cargo_registry"],
  go: ["goproxy_server", "git_source"],
} as const;

const NEW_LANGUAGE_TO_REGISTRY_TYPE: Required<RegistryMapping> = {
  actions: [],
  cpp: [],
  java: ["maven_repository"],
  csharp: ["nuget_feed"],
  javascript: [],
  python: [],
  ruby: [],
  rust: [],
  swift: [],
  go: ["goproxy_server", "git_source"],
} as const;

/**
 * Extracts an `Address` value from the given `Registry` value by determining whether it has
 * a `url` value, or no `url` value but a `host` value.
 *
 * @throws A `ConfigurationError` if the `Registry` value contains neither a `url` or `host` field.
 */
function getRegistryAddress(
  registry: json.UnvalidatedObject<Registry>,
): Address {
  if (
    isDefined(registry.url) &&
    json.isString(registry.url) &&
    json.isStringOrUndefined(registry.host)
  ) {
    return {
      url: registry.url,
      host: registry.host,
    };
  } else if (isDefined(registry.host) && json.isString(registry.host)) {
    return {
      url: undefined,
      host: registry.host,
    };
  } else {
    // The proxy needs one of these to work. If both are defined, the url has the precedence.
    throw new ConfigurationError(
      "Invalid credentials - must specify host or url",
    );
  }
}

// getCredentials returns registry credentials from action inputs.
// It prefers `registries_credentials` over `registry_secrets`.
// If neither is set, it returns an empty array.
export function getCredentials(
  logger: Logger,
  registrySecrets: string | undefined,
  registriesCredentials: string | undefined,
  language: BuiltInLanguage | undefined,
  skipUnusedRegistries: boolean = false,
): Credential[] {
  const registryMapping = skipUnusedRegistries
    ? NEW_LANGUAGE_TO_REGISTRY_TYPE
    : LANGUAGE_TO_REGISTRY_TYPE;
  const registryTypeForLanguage = language
    ? registryMapping[language]
    : undefined;

  let credentialsStr: string;
  if (registriesCredentials !== undefined) {
    logger.info(`Using registries_credentials input.`);
    credentialsStr = Buffer.from(registriesCredentials, "base64").toString();
  } else if (registrySecrets !== undefined) {
    logger.info(`Using registry_secrets input.`);
    credentialsStr = registrySecrets;
  } else {
    logger.info(`No credentials defined.`);
    return [];
  }

  // Parse and validate the credentials
  let parsed: unknown;
  try {
    parsed = json.parseString(credentialsStr);
  } catch {
    // Don't log the error since it might contain sensitive information.
    logger.error("Failed to parse the credentials data.");
    throw new ConfigurationError("Invalid credentials format.");
  }

  // Check that the parsed data is indeed an array.
  if (!json.isArray(parsed)) {
    throw new ConfigurationError(
      "Expected credentials data to be an array of configurations, but it is not.",
    );
  }

  const out: Credential[] = [];
  for (const e of parsed) {
    if (e === null || !json.isObject<RawCredential>(e)) {
      throw new ConfigurationError("Invalid credentials - must be an object");
    }

    // The configuration must have a type.
    if (!isDefined(e.type) || !json.isString(e.type)) {
      throw new ConfigurationError("Invalid credentials - must have a type");
    }

    // Mask credentials to reduce chance of accidental leakage in logs.
    const authConfig = getAuthConfig(e);
    const address = getRegistryAddress(e);

    // Filter credentials based on language if specified. `type` is the registry type.
    // E.g., "maven_feed" for Java/Kotlin, "nuget_repository" for C#.
    if (
      registryTypeForLanguage &&
      !registryTypeForLanguage.some((t) => t === e.type)
    ) {
      continue;
    }

    const isPrintable = (str: string | undefined): boolean => {
      return str ? /^[\x20-\x7E]*$/.test(str) : true;
    };

    // Ensure that all string fields only contain printable characters.
    for (const key of Object.keys(e)) {
      const val = e[key];
      if (typeof val === "string" && !isPrintable(val)) {
        throw new ConfigurationError(
          "Invalid credentials - fields must contain only printable characters",
        );
      }
    }

    // If the password or token looks like a GitHub PAT, warn if no username is configured.
    const noUsername =
      !hasUsername(authConfig) || !isDefined(authConfig.username);
    const passwordIsPAT =
      hasUsernameAndPassword(authConfig) &&
      isDefined(authConfig.password) &&
      isPAT(authConfig.password);
    const tokenIsPAT =
      hasToken(authConfig) &&
      isDefined(authConfig.token) &&
      isPAT(authConfig.token);

    if (noUsername && (passwordIsPAT || tokenIsPAT)) {
      logger.warning(
        `A ${e.type} private registry is configured for ${e.host || e.url} using a GitHub Personal Access Token (PAT), but no username was provided. ` +
          `This may not work correctly. When configuring a private registry using a PAT, select "Username and password" and enter the username of the user ` +
          `who generated the PAT.`,
      );
    }

    // Construct the base credential object.
    const baseCredential: Omit<Registry, keyof Address> = { type: e.type };

    // If "replaces-base" is present, it must be a boolean.
    if ("replaces-base" in e) {
      if (
        isDefined(e["replaces-base"]) &&
        typeof e["replaces-base"] === "boolean"
      ) {
        baseCredential["replaces-base"] = e["replaces-base"];
      } else {
        throw new ConfigurationError(
          "Invalid credentials - 'replaces-base' must be a boolean",
        );
      }
    }

    out.push({
      ...baseCredential,
      ...authConfig,
      ...address,
    });
  }
  return out;
}

/**
 * Gets the name of the proxy release asset for the current platform.
 */
export function getProxyPackage(): string {
  const platform =
    process.platform === "win32"
      ? "win64"
      : process.platform === "darwin"
        ? "osx64"
        : "linux64";
  return `${UPDATEJOB_PROXY}-${platform}.tar.gz`;
}

/**
 * Gets the fallback URL for downloading the proxy release asset.
 *
 * @param proxyPackage The asset name.
 * @returns The full URL to download the specified asset from the fallback release.
 */
export function getFallbackUrl(proxyPackage: string): string {
  return `${UPDATEJOB_PROXY_URL_PREFIX}${proxyPackage}`;
}

/**
 * Uses the GitHub API to obtain information about the CodeQL CLI bundle release
 * that is tagged by `version`.
 *
 * @returns The response from the GitHub API.
 */
async function getReleaseByVersion(version: string) {
  return getApiClient().rest.repos.getReleaseByTag({
    owner: "github",
    repo: "codeql-action",
    tag: version,
  });
}

/** Uses `features` to determine the default CLI version. */
async function getCliVersionFromFeatures(
  features: FeatureEnablement,
): Promise<CodeQLDefaultVersionInfo> {
  const gitHubVersion = await getGitHubVersion();
  return await features.getEnabledDefaultCliVersions(gitHubVersion.type);
}

/**
 * Determines the URL of the proxy release asset that we should download if its not
 * already in the toolcache, and its version.
 *
 * @param logger The logger to use.
 * @param features Information about enabled features.
 * @returns Returns the download URL and version of the proxy package we plan to use.
 */
export async function getDownloadUrl(
  logger: Logger,
  features: FeatureEnablement,
): Promise<{ url: string; version: string }> {
  const proxyPackage = getProxyPackage();

  try {
    const useFeaturesToDetermineCLI = await features.getValue(
      Feature.StartProxyUseFeaturesRelease,
    );

    // Retrieve information about the CLI version we should use. This will be either the linked
    // version, or the one enabled by FFs.
    const versionInfo = useFeaturesToDetermineCLI
      ? (await getCliVersionFromFeatures(features)).enabledVersions[0]
      : {
          cliVersion: defaults.cliVersion,
          tagName: defaults.bundleVersion,
        };

    // Try to retrieve information about the CLI bundle release identified by `versionInfo`.
    const cliRelease = await getReleaseByVersion(versionInfo.tagName);

    // Search the release's assets to find the one we are looking for.
    for (const asset of cliRelease.data.assets) {
      if (asset.name === proxyPackage) {
        logger.info(
          `Found '${proxyPackage}' in release '${versionInfo.tagName}' at '${asset.url}'`,
        );
        return {
          url: asset.url,
          // The `update-job-proxy` doesn't have a version as such. Since we now bundle it
          // with CodeQL CLI bundle releases, we use the corresponding CLI version to
          // differentiate between (potentially) different versions of `update-job-proxy`.
          version: versionInfo.cliVersion,
        };
      }
    }
  } catch (ex) {
    logger.warning(
      `Failed to retrieve information about the linked release: ${getErrorMessage(ex)}`,
    );
  }

  // Fallback to the hard-coded URL.
  logger.info(
    `Did not find '${proxyPackage}' in the linked release, falling back to hard-coded version.`,
  );
  return {
    url: getFallbackUrl(proxyPackage),
    version: UPDATEJOB_PROXY_VERSION,
  };
}

/**
 * Attempts to download a file from `url` into the toolcache.
 *
 * @param logger The logger to use.
 * @param url The URL to download the proxy binary from.
 * @param authorization The authorization information to use.
 * @returns If successful, the path to the downloaded file.
 */
export async function downloadProxy(
  logger: Logger,
  url: string,
  authorization: string | undefined,
) {
  try {
    // Download the proxy archive from `url`. We let `downloadTool` choose where
    // to store it. The path to the downloaded file will be returned if successful.
    return toolcache.downloadTool(url, /* dest: */ undefined, authorization, {
      accept: "application/octet-stream",
    });
  } catch (error) {
    logger.error(
      `Failed to download proxy archive from ${url}: ${getErrorMessage(error)}`,
    );
    throw new StartProxyError(StartProxyErrorType.DownloadFailed);
  }
}

/**
 * Attempts to extract the proxy binary from the `archive`.
 *
 * @param logger The logger to use.
 * @param archive The archive to extract.
 * @returns The path to the extracted file(s).
 */
export async function extractProxy(logger: Logger, archive: string) {
  try {
    return await toolcache.extractTar(archive);
  } catch (error) {
    logger.error(
      `Failed to extract proxy archive from ${archive}: ${getErrorMessage(error)}`,
    );
    throw new StartProxyError(StartProxyErrorType.ExtractionFailed);
  }
}

/**
 * Attempts to store the proxy in the toolcache.
 *
 * @param logger The logger to use.
 * @param source The source path to add to the toolcache.
 * @param filename The filename of the proxy binary.
 * @param version The version of the proxy.
 * @returns The path to the directory in the toolcache.
 */
export async function cacheProxy(
  logger: Logger,
  source: string,
  filename: string,
  version: string,
) {
  try {
    return await toolcache.cacheDir(source, filename, version);
  } catch (error) {
    logger.error(
      `Failed to add proxy archive from ${source} to toolcache: ${getErrorMessage(error)}`,
    );
    throw new StartProxyError(StartProxyErrorType.CacheFailed);
  }
}

/**
 * Returns the platform-specific filename of the proxy binary.
 */
export function getProxyFilename() {
  return process.platform === "win32"
    ? `${UPDATEJOB_PROXY}.exe`
    : UPDATEJOB_PROXY;
}

/**
 * Gets a path to the proxy binary. If possible, this function will find the proxy in the
 * runner's tool cache. Otherwise, it downloads and extracts the proxy binary,
 * and stores it in the tool cache.
 *
 * @param logger The logger to use.
 * @returns The path to the proxy binary.
 */
export async function getProxyBinaryPath(
  logger: Logger,
  features: FeatureEnablement,
): Promise<string> {
  const proxyFileName = getProxyFilename();
  const proxyInfo = await getDownloadUrl(logger, features);

  let proxyBin = toolcache.find(proxyFileName, proxyInfo.version);
  if (!proxyBin) {
    const apiDetails = getApiDetails();
    const authorization = getAuthorizationHeaderFor(
      logger,
      apiDetails,
      proxyInfo.url,
    );
    const temp = await downloadProxy(logger, proxyInfo.url, authorization);
    const extracted = await extractProxy(logger, temp);
    proxyBin = await cacheProxy(
      logger,
      extracted,
      proxyFileName,
      proxyInfo.version,
    );
  }
  return path.join(proxyBin, proxyFileName);
}
