import * as fs from 'fs';
import * as path from 'path';

import { BAZEL_OUT_REGEX, REPO_MAPPING_RLOCATION } from './paths';
import { RepoMappings, callerRepository } from './repository';

/**
 * Class that provides methods for resolving Bazel runfiles.
 */
export class Runfiles {
  manifest: Map<string, string>|undefined;
  runfilesDir: string|undefined;
  /**
   * If the environment gives us enough hints, we can know the workspace name
   */
  workspace: string|undefined;
  /**
   * If the environment gives us enough hints, we can know the package path
   */
  package: string|undefined;
  /**
   * If the environment has repo mappings, we can use them to resolve repo relative paths.
   */
  repoMappings: RepoMappings|undefined;

  constructor(private _env = process.env) {
    // If Bazel sets a variable pointing to a runfiles manifest,
    // we'll always use it.
    // Note that this has a slight performance implication on Mac/Linux
    // where we could use the runfiles tree already laid out on disk
    // but this just costs one file read for the external npm/node_modules
    // and one for each first-party module, not one per file.
    if (!!_env['RUNFILES_MANIFEST_FILE']) {
      this.manifest = this.loadRunfilesManifest(_env['RUNFILES_MANIFEST_FILE']!);
    } else if (!!_env['RUNFILES_DIR']) {
      this.runfilesDir = path.resolve(_env['RUNFILES_DIR']!);
      this.repoMappings = this.parseRepoMapping(this.runfilesDir);
    } else if (!!_env['RUNFILES']) {
      this.runfilesDir = path.resolve(_env['RUNFILES']!);
      this.repoMappings = this.parseRepoMapping(this.runfilesDir);
    } else {
      throw new Error(
          'Every node program run under Bazel must have a $RUNFILES_DIR, $RUNFILES or $RUNFILES_MANIFEST_FILE environment variable');
    }
    // Under --noenable_runfiles (in particular on Windows)
    // Bazel sets RUNFILES_MANIFEST_ONLY=1.
    // When this happens, we need to read the manifest file to locate
    // inputs
    if (_env['RUNFILES_MANIFEST_ONLY'] === '1' && !_env['RUNFILES_MANIFEST_FILE']) {
      console.warn(`Workaround https://github.com/bazelbuild/bazel/issues/7994
                 RUNFILES_MANIFEST_FILE should have been set but wasn't.
                 falling back to using runfiles symlinks.
                 If you want to test runfiles manifest behavior, add
                 --spawn_strategy=standalone to the command line.`);
    }
    // Bazel starts actions with pwd=execroot/my_wksp or pwd=runfiles/my_wksp
    this.workspace = _env['BAZEL_WORKSPACE'] || _env['JS_BINARY__WORKSPACE'] || undefined;
    // If target is from an external workspace such as @npm//rollup/bin:rollup
    // resolvePackageRelative is not supported since package is in an external
    // workspace.
    let target = _env['BAZEL_TARGET'] || _env['JS_BINARY__TARGET'];
    if (!!target && !target.startsWith('@')) {
      // //path/to:target -> path/to
      this.package = target.split(':')[0].replace(/^\/\//, '');
    }
  }

  /** Resolves the given path from the runfile manifest. */
  private _resolveFromManifest(searchPath: string): string|undefined {
    if (!this.manifest) return undefined;

    let result: string|undefined;
    for (const [k, v] of this.manifest) {
      // Account for Bazel --legacy_external_runfiles
      // which pollutes the workspace with 'my_wksp/external/...'
      if (k.startsWith(`${searchPath}/external`)) continue;

      // If the manifest entry fully matches, return the value path without
      // considering other manifest entries. We already have an exact match.
      if (k === searchPath) {
        return v;
      }

      // Consider a case where `npm/node_modules` is resolved, and we have the following
      // manifest: `npm/node_modules/semver/LICENSE
      // /path/to/external/npm/node_modules/semver/LICENSE` To resolve the directory, we look for
      // entries that either fully match, or refer to contents within the directory we are looking
      // for. We can then subtract the child path to resolve the directory. e.g. in the case above
      // we subtract `length(`/semver/LICENSE`)` from the entry value.
      if (k.startsWith(`${searchPath}/`)) {
        const l = k.length - searchPath.length;
        const maybe = v.substring(0, v.length - l);
        if (maybe.match(BAZEL_OUT_REGEX)) {
          return maybe;
        } else {
          result = maybe;
        }
      }
    }
    return result;
  }


  /**
   * The runfiles manifest maps from short_path
   * https://docs.bazel.build/versions/main/skylark/lib/File.html#short_path
   * to the actual location on disk where the file can be read.
   *
   * In a sandboxed execution, it does not exist. In that case, runfiles must be
   * resolved from a symlink tree under the runfiles dir.
   * See https://github.com/bazelbuild/bazel/issues/3726
   */
  loadRunfilesManifest(manifestPath: string) {
    const runfilesEntries = new Map();
    const input = fs.readFileSync(manifestPath, {encoding: 'utf-8'});

    for (const line of input.split('\n')) {
      if (!line) continue;
      const [runfilesPath, realPath] = line.split(' ');
      runfilesEntries.set(runfilesPath, realPath);
    }

    return runfilesEntries;
  }

  parseRepoMapping(runfilesDir: string): RepoMappings | undefined {
    const repoMappingPath = path.join(runfilesDir, REPO_MAPPING_RLOCATION);

    if (!fs.existsSync(repoMappingPath)) {
      // The repo mapping manifest only exists with Bzlmod, so it's not an
      // error if it's missing. Since any repository name not contained in the
      // mapping is assumed to be already canonical, an empty map is
      // equivalent to not applying any mapping.
      return Object.create(null);
    }

    const repoMappings: RepoMappings = Object.create(null);
    const mappings = fs.readFileSync(repoMappingPath, { encoding: "utf-8" });

    // Each line of the repository mapping manifest has the form:
    // canonical name of source repo,apparent name of target repo,target repo runfiles directory
    // https://cs.opensource.google/bazel/bazel/+/1b073ac0a719a09c9b2d1a52680517ab22dc971e:src/main/java/com/google/devtools/build/lib/analysis/RepoMappingManifestAction.java;l=117
    for (const line of mappings.split("\n")) {
      if (!line) continue;

      const [sourceRepo, targetRepoApparentName, targetRepoDirectory] = line.split(",");

      (repoMappings[sourceRepo] ??= Object.create(null))[targetRepoApparentName] = targetRepoDirectory;
    }
    return repoMappings;
  }

  /** Resolves the given module path. */
  resolve(modulePath: string, sourceRepo?: string): string {
    // Normalize path by converting to forward slashes and removing all trailing
    // forward slashes
    modulePath = modulePath.replace(/\\/g, '/').replace(/\/+$/g, '')
    if (path.isAbsolute(modulePath)) {
      return modulePath;
    }

    if (this.repoMappings) {
      // Determine the repository which runfiles is being invoked from by default.
      if (sourceRepo === undefined) {
        sourceRepo = callerRepository();
      }

      // If the repository mappings were loaded ensure the source repository is valid.
      if (!(sourceRepo in this.repoMappings)) {
        throw new Error(
          `source repository ${sourceRepo} not found in repo mappings: ${JSON.stringify(
            this.repoMappings,
            null,
            2,
          )}`,
        );
      }
    }

    const result = this._resolve(sourceRepo, modulePath, undefined);
    if (result) {
      return result;
    }
    const e = new Error(`could not resolve module ${modulePath}`);
    (e as any).code = 'MODULE_NOT_FOUND';
    throw e;
  }

  /** Resolves the given path relative to the current Bazel workspace. */
  resolveWorkspaceRelative(modulePath: string) {
    // Normalize path by converting to forward slashes and removing all trailing
    // forward slashes
    modulePath = modulePath.replace(/\\/g, '/').replace(/\/+$/g, '')
    if (!this.workspace) {
      throw new Error(
          'workspace could not be determined from the environment; make sure BAZEL_WORKSPACE is set');
    }
    return this.resolve(path.posix.join(this.workspace, modulePath));
  }

  /** Resolves the given path relative to the current Bazel package. */
  resolvePackageRelative(modulePath: string) {
    // Normalize path by converting to forward slashes and removing all trailing
    // forward slashes
    modulePath = modulePath.replace(/\\/g, '/').replace(/\/+$/g, '')
    if (!this.workspace) {
      throw new Error(
          'workspace could not be determined from the environment; make sure BAZEL_WORKSPACE is set');
    }
    // NB: this.package may be '' if at the root of the workspace
    if (this.package === undefined) {
      throw new Error(
          'package could not be determined from the environment; make sure BAZEL_TARGET is set');
    }
    return this.resolve(path.posix.join(this.workspace, this.package, modulePath));
  }

  /**
   * Patches the default Node.js resolution to support runfile resolution.
   * @deprecated Use the runfile helpers directly instead.
   **/
  patchRequire() {
    const requirePatch = this._env['BAZEL_NODE_PATCH_REQUIRE'];
    if (!requirePatch) {
      throw new Error('require patch location could not be determined from the environment');
    }
    require(requirePatch);
  }

  /** Helper for resolving a given module recursively in the runfiles. */
  private _resolve(
    sourceRepo: string,
    moduleBase: string,
    moduleTail: string | undefined,
  ): string | undefined {
    if (this.manifest) {
      const result = this._resolveFromManifest(moduleBase);
      if (result) {
        if (moduleTail) {
          const maybe = path.join(result, moduleTail || '');
          if (fs.existsSync(maybe)) {
            return maybe;
          }
        } else {
          return result;
        }
      }
    }

    // Apply repo mappings to the moduleBase if it is a known repo.
    if (this.repoMappings && moduleBase in this.repoMappings[sourceRepo]) {
      const mappedRepo = this.repoMappings[sourceRepo][moduleBase];
      if (mappedRepo !== moduleBase) {
        const maybe = this._resolve(sourceRepo, mappedRepo, moduleTail);
        if (maybe !== undefined) {
          return maybe;
        }
      }
    }
    if (this.runfilesDir) {
      const maybe = path.join(this.runfilesDir, moduleBase, moduleTail || '');
      if (fs.existsSync(maybe)) {
        return maybe;
      }
    }
    const dirname = path.dirname(moduleBase);
    if (dirname == '.') {
      // no match
      return undefined;
    }
    return this._resolve(
      sourceRepo,
      dirname,
      path.join(path.basename(moduleBase), moduleTail || ""),
    );
  }
}
