// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.
// See LICENSE in the project root for license information.

import { sep as directorySeparator } from 'node:path';

import { LookupByPath, type IPrefixMatch } from '@rushstack/lookup-by-path';

/**
 * Information about a local or installed npm package.
 * @beta
 */
export interface ISerializedResolveContext {
  /**
   * The path to the root folder of this context.
   * This path is normalized to use `/` as the separator and should not end with a trailing `/`.
   */
  root: string;
  /**
   * The name of this package. Used to inject a self-reference into the dependency map.
   */
  name: string;
  /**
   * Map of declared dependencies (if any) to the ordinal of the corresponding context.
   */
  deps?: Record<string, number>;
  /**
   * Set of relative paths to nested `package.json` files within this context.
   * These paths are normalized to use `/` as the separator and should not begin with a leading `./`.
   */
  dirInfoFiles?: string[];
}

/**
 * The serialized form of the cache file. This file is expected to be generated by a separate tool from
 * information known to the package manager. Namely, the dependency relationships between packages, and
 * all the `package.json` files in the workspace (installed or local).
 * @beta
 */
export interface IResolverCacheFile {
  /**
   * The base path. All paths in context entries are prefixed by this path.
   */
  basePath: string;
  /**
   * The ordered list of all contexts in the cache
   */
  contexts: ISerializedResolveContext[];
}

/**
 * A context for resolving dependencies in a workspace.
 * @beta
 */
export interface IResolveContext {
  /**
   * The absolute path to the root folder of this context
   */
  descriptionFileRoot: string;
  /**
   * Find the context that corresponds to a module specifier, when requested in the current context.
   * @param request - The module specifier to resolve
   */
  findDependency(request: string): IPrefixMatch<IResolveContext> | undefined;
}

/**
 * Options for creating a `WorkspaceLayoutCache`.
 * @beta
 */
export interface IWorkspaceLayoutCacheOptions {
  /**
   * The parsed cache data. File reading is left as an exercise for the caller.
   */
  cacheData: IResolverCacheFile;
  /**
   * The directory separator used in the `path` field of the resolver inputs.
   * Will usually be `path.sep`.
   */
  resolverPathSeparator?: '/' | '\\';
}

/**
 * A function that normalizes a path to a platform-specific format (if needed).
 * Will be undefined if the platform uses `/` as the path separator.
 *
 * @beta
 */
export type IPathNormalizationFunction = ((input: string) => string) | undefined;

function backslashToSlash(path: string): string {
  return path.replace(/\\/g, '/');
}

function slashToBackslash(path: string): string {
  return path.replace(/\//g, '\\');
}

/**
 * A cache of workspace layout information.
 * @beta
 */
export class WorkspaceLayoutCache {
  /**
   * A lookup of context roots to their corresponding context objects
   */
  public readonly contextLookup: LookupByPath<IResolveContext>;
  /**
   * A weak map of package JSON contents to their corresponding context objects
   */
  public readonly contextForPackage: WeakMap<object, IPrefixMatch<IResolveContext>>;

  public readonly resolverPathSeparator: string;
  public readonly normalizeToSlash: IPathNormalizationFunction;
  public readonly normalizeToPlatform: IPathNormalizationFunction;

  public constructor(options: IWorkspaceLayoutCacheOptions) {
    const { cacheData, resolverPathSeparator = directorySeparator } = options;

    if (resolverPathSeparator !== '/' && resolverPathSeparator !== '\\') {
      throw new Error(`Unsupported directory separator: ${resolverPathSeparator}`);
    }

    const { basePath } = cacheData;
    const resolveContexts: ResolveContext[] = [];
    const contextLookup: LookupByPath<IResolveContext> = new LookupByPath(undefined, resolverPathSeparator);

    this.contextLookup = contextLookup;
    this.contextForPackage = new WeakMap<object, IPrefixMatch<IResolveContext>>();

    const normalizeToSlash: IPathNormalizationFunction =
      resolverPathSeparator === '\\' ? backslashToSlash : undefined;
    const normalizeToPlatform: IPathNormalizationFunction =
      resolverPathSeparator === '\\' ? slashToBackslash : undefined;

    this.resolverPathSeparator = resolverPathSeparator;
    this.normalizeToSlash = normalizeToSlash;
    this.normalizeToPlatform = normalizeToPlatform;

    // Internal class due to coupling to `resolveContexts`
    class ResolveContext implements IResolveContext {
      private readonly _serialized: ISerializedResolveContext;
      private _descriptionFileRoot: string | undefined;
      private _dependencies: LookupByPath<IResolveContext> | undefined;

      public constructor(serialized: ISerializedResolveContext) {
        this._serialized = serialized;
        this._descriptionFileRoot = undefined;
        this._dependencies = undefined;
      }

      public get descriptionFileRoot(): string {
        if (!this._descriptionFileRoot) {
          const merged: string = `${basePath}${this._serialized.root}`;
          this._descriptionFileRoot = normalizeToPlatform?.(merged) ?? merged;
        }
        return this._descriptionFileRoot;
      }

      public findDependency(request: string): IPrefixMatch<IResolveContext> | undefined {
        if (!this._dependencies) {
          // Lazy initialize this object since most packages won't be requested.
          const dependencies: LookupByPath<IResolveContext> = new LookupByPath(undefined, '/');

          const { name, deps } = this._serialized;

          // Handle the self-reference scenario
          dependencies.setItem(name, this);
          if (deps) {
            for (const [key, ordinal] of Object.entries(deps)) {
              // This calls into the array of instances that is owned by WorkpaceLayoutCache
              dependencies.setItem(key, resolveContexts[ordinal]);
            }
          }
          this._dependencies = dependencies;
        }

        return this._dependencies.findLongestPrefixMatch(request);
      }
    }

    for (const serialized of cacheData.contexts) {
      const resolveContext: ResolveContext = new ResolveContext(serialized);
      resolveContexts.push(resolveContext);

      contextLookup.setItemFromSegments(
        concat<string>(
          // All paths in the cache file are platform-agnostic
          LookupByPath.iteratePathSegments(basePath, '/'),
          LookupByPath.iteratePathSegments(serialized.root, '/')
        ),
        resolveContext
      );

      // Handle nested package.json files. These may modify some properties, but the dependency resolution
      // will match the original package root. Typically these are used to set the `type` field to `module`.
      if (serialized.dirInfoFiles) {
        for (const file of serialized.dirInfoFiles) {
          contextLookup.setItemFromSegments(
            concat<string>(
              // All paths in the cache file are platform-agnostic
              concat<string>(
                LookupByPath.iteratePathSegments(basePath, '/'),
                LookupByPath.iteratePathSegments(serialized.root, '/')
              ),
              LookupByPath.iteratePathSegments(file, '/')
            ),
            resolveContext
          );
        }
      }
    }
  }
}

function* concat<T>(a: Iterable<T>, b: Iterable<T>): IterableIterator<T> {
  yield* a;
  yield* b;
}
