import { output } from '../utils/output';
import { relative } from 'path';
import { Task, TaskGraph } from '../config/task-graph';
import { ProjectGraph, ProjectGraphProjectNode } from '../config/project-graph';
import { TargetDependencyConfig } from '../config/workspace-json-project-json';
import { workspaceRoot } from '../utils/workspace-root';
import { joinPathFragments } from '../utils/path';
import { isRelativePath } from '../utils/fileutils';
import { serializeOverridesIntoCommandLine } from '../utils/serialize-overrides-into-command-line';
import { splitByColons } from '../utils/split-target';
import { getExecutorInformation } from '../command-line/run/executor-utils';
import { CustomHasher } from '../config/misc-interfaces';

export function getCommandAsString(execCommand: string, task: Task) {
  const args = getPrintableCommandArgsForTask(task);
  return [execCommand, 'nx', ...args].join(' ').trim();
}

export function getDependencyConfigs(
  { project, target }: { project: string; target: string },
  defaultDependencyConfigs: Record<string, (TargetDependencyConfig | string)[]>,
  projectGraph: ProjectGraph
): TargetDependencyConfig[] | undefined {
  const dependencyConfigs = (
    projectGraph.nodes[project].data?.targets[target]?.dependsOn ??
    defaultDependencyConfigs[target] ??
    []
  ).map((config) =>
    typeof config === 'string'
      ? expandDependencyConfigSyntaxSugar(config, projectGraph)
      : config
  );
  for (const dependencyConfig of dependencyConfigs) {
    if (dependencyConfig.projects && dependencyConfig.dependencies) {
      output.error({
        title: `dependsOn is improperly configured for ${project}:${target}`,
        bodyLines: [
          `dependsOn.projects and dependsOn.dependencies cannot be used together.`,
        ],
      });
      process.exit(1);
    }
  }
  return dependencyConfigs;
}

export function expandDependencyConfigSyntaxSugar(
  dependencyConfigString: string,
  graph: ProjectGraph
): TargetDependencyConfig {
  const [dependencies, targetString] = dependencyConfigString.startsWith('^')
    ? [true, dependencyConfigString.substring(1)]
    : [false, dependencyConfigString];

  // Support for `project:target` syntax doesn't make sense for
  // dependencies, so we only support `target` syntax for dependencies.
  if (dependencies) {
    return {
      target: targetString,
      dependencies: true,
    };
  }

  // Support for both `project:target` and `target:with:colons` syntax
  const [maybeProject, ...segments] = splitByColons(targetString);

  // if no additional segments are provided, then the string references
  // a target of the same project
  if (!segments.length) {
    return { target: maybeProject };
  }

  return {
    // Only the first segment could be a project. If it is, the rest is a target.
    // If its not, then the whole targetString was a target with colons in its name.
    target: maybeProject in graph.nodes ? segments.join(':') : targetString,
    // If the first segment is a project, then we have a specific project. Otherwise, we don't.
    projects: maybeProject in graph.nodes ? [maybeProject] : undefined,
  };
}

export function getOutputs(
  p: Record<string, ProjectGraphProjectNode>,
  task: Task
) {
  return getOutputsForTargetAndConfiguration(task, p[task.target.project]);
}

class InvalidOutputsError extends Error {
  constructor(public outputs: string[], public invalidOutputs: Set<string>) {
    super(InvalidOutputsError.createMessage(invalidOutputs));
  }

  private static createMessage(invalidOutputs: Set<string>) {
    const invalidOutputsList =
      '\n - ' + Array.from(invalidOutputs).join('\n - ');
    return `The following outputs are invalid:${invalidOutputsList}\nPlease run "nx repair" to repair your configuration`;
  }
}

export function validateOutputs(outputs: string[]) {
  const invalidOutputs = new Set<string>();

  for (const output of outputs) {
    if (!/^{[\s\S]+}/.test(output)) {
      invalidOutputs.add(output);
    }
  }
  if (invalidOutputs.size > 0) {
    throw new InvalidOutputsError(outputs, invalidOutputs);
  }
}

export function transformLegacyOutputs(
  projectRoot: string,
  error: InvalidOutputsError
) {
  return error.outputs.map((output) => {
    if (!error.invalidOutputs.has(output)) {
      return output;
    }

    const relativePath = isRelativePath(output)
      ? output
      : relative(projectRoot, output);

    const isWithinProject = !relativePath.startsWith('..');
    return joinPathFragments(
      isWithinProject ? '{projectRoot}' : '{workspaceRoot}',
      isWithinProject ? relativePath : output
    );
  });
}

/**
 * Returns the list of outputs that will be cached.
 * @param task target + overrides
 * @param node ProjectGraphProjectNode object that the task runs against
 */
export function getOutputsForTargetAndConfiguration(
  task: Pick<Task, 'target' | 'overrides'>,
  node: ProjectGraphProjectNode
): string[] {
  const { target, configuration } = task.target;

  const targetConfiguration = node.data.targets[target];

  const options = {
    ...targetConfiguration.options,
    ...targetConfiguration?.configurations?.[configuration],
    ...task.overrides,
  };

  if (targetConfiguration?.outputs) {
    try {
      validateOutputs(targetConfiguration.outputs);
    } catch (error) {
      if (error instanceof InvalidOutputsError) {
        // TODO(@FrozenPandaz): In v17, throw this error and do not transform.
        console.warn(error.message);
        targetConfiguration.outputs = transformLegacyOutputs(
          node.data.root,
          error
        );
      } else {
        throw error;
      }
    }

    return targetConfiguration.outputs
      .map((output: string) => {
        return interpolate(output, {
          projectRoot: node.data.root,
          projectName: node.name,
          project: { ...node.data, name: node.name }, // this is legacy
          options,
        });
      })
      .filter(
        (output) =>
          !!output && !output.match(/{(projectRoot|workspaceRoot|(options.*))}/)
      );
  }

  // Keep backwards compatibility in case `outputs` doesn't exist
  if (options.outputPath) {
    return Array.isArray(options.outputPath)
      ? options.outputPath
      : [options.outputPath];
  } else if (target === 'build' || target === 'prepare') {
    return [
      `dist/${node.data.root}`,
      `${node.data.root}/dist`,
      `${node.data.root}/build`,
      `${node.data.root}/public`,
    ];
  } else {
    return [];
  }
}

export function interpolate(template: string, data: any): string {
  if (template.includes('{workspaceRoot}', 1)) {
    throw new Error(
      `Output '${template}' is invalid. {workspaceRoot} can only be used at the beginning of the expression.`
    );
  }

  if (data.projectRoot == '.' && template.includes('{projectRoot}', 1)) {
    throw new Error(
      `Output '${template}' is invalid. When {projectRoot} is '.', it can only be used at the beginning of the expression.`
    );
  }

  let res = template.replace('{workspaceRoot}/', '');

  if (data.projectRoot == '.') {
    res = res.replace('{projectRoot}/', '');
  }

  return res.replace(/{([\s\S]+?)}/g, (match: string) => {
    let value = data;
    let path = match.slice(1, -1).trim().split('.');
    for (let idx = 0; idx < path.length; idx++) {
      if (!value[path[idx]]) {
        return match;
      }
      value = value[path[idx]];
    }
    return value;
  });
}

export async function getExecutorNameForTask(
  task: Task,
  projectGraph: ProjectGraph
) {
  const project = projectGraph.nodes[task.target.project].data;
  return project.targets[task.target.target].executor;
}

export async function getExecutorForTask(
  task: Task,
  projectGraph: ProjectGraph
) {
  const executor = await getExecutorNameForTask(task, projectGraph);
  const [nodeModule, executorName] = executor.split(':');

  return getExecutorInformation(nodeModule, executorName, workspaceRoot);
}

export async function getCustomHasher(
  task: Task,
  projectGraph: ProjectGraph
): Promise<CustomHasher> | null {
  const factory = (await getExecutorForTask(task, projectGraph)).hasherFactory;
  return factory ? factory() : null;
}

export function removeTasksFromTaskGraph(
  graph: TaskGraph,
  ids: string[]
): TaskGraph {
  const tasks = {};
  const dependencies = {};
  const removedSet = new Set(ids);
  for (let taskId of Object.keys(graph.tasks)) {
    if (!removedSet.has(taskId)) {
      tasks[taskId] = graph.tasks[taskId];
      dependencies[taskId] = graph.dependencies[taskId].filter(
        (depTaskId) => !removedSet.has(depTaskId)
      );
    }
  }
  return {
    tasks,
    dependencies: dependencies,
    roots: Object.keys(dependencies).filter(
      (k) => dependencies[k].length === 0
    ),
  };
}

export function calculateReverseDeps(
  taskGraph: TaskGraph
): Record<string, string[]> {
  const reverseTaskDeps: Record<string, string[]> = {};
  Object.keys(taskGraph.tasks).forEach((t) => {
    reverseTaskDeps[t] = [];
  });

  Object.keys(taskGraph.dependencies).forEach((taskId) => {
    taskGraph.dependencies[taskId].forEach((d) => {
      reverseTaskDeps[d].push(taskId);
    });
  });

  return reverseTaskDeps;
}

export function getCliPath() {
  return require.resolve(`../../bin/run-executor.js`);
}

export function getPrintableCommandArgsForTask(task: Task) {
  const args: string[] = task.overrides['__overrides_unparsed__'];

  const target = task.target.target.includes(':')
    ? `"${task.target.target}"`
    : task.target.target;

  const config = task.target.configuration
    ? `:${task.target.configuration}`
    : '';

  return ['run', `${task.target.project}:${target}${config}`, ...args];
}

export function getSerializedArgsForTask(task: Task, isVerbose: boolean) {
  return [
    JSON.stringify({
      targetDescription: task.target,
      overrides: task.overrides,
      isVerbose: isVerbose,
    }),
  ];
}

export function shouldStreamOutput(
  task: Task,
  initiatingProject: string | null,
  options: {
    cacheableOperations?: string[] | null;
    cacheableTargets?: string[] | null;
  }
): boolean {
  if (process.env.NX_STREAM_OUTPUT === 'true') return true;
  if (longRunningTask(task)) return true;
  if (task.target.project === initiatingProject) return true;
  return false;
}

export function isCacheableTask(
  task: Task,
  options: {
    cacheableOperations?: string[] | null;
    cacheableTargets?: string[] | null;
  }
): boolean {
  const cacheable = options.cacheableOperations || options.cacheableTargets;
  return (
    cacheable &&
    cacheable.indexOf(task.target.target) > -1 &&
    !longRunningTask(task)
  );
}

function longRunningTask(task: Task) {
  const t = task.target.target;
  return (
    (!!task.overrides['watch'] && task.overrides['watch'] !== 'false') ||
    t.endsWith(':watch') ||
    t.endsWith('-watch') ||
    t === 'serve' ||
    t === 'dev' ||
    t === 'start'
  );
}

// TODO: vsavkin remove when nx-cloud doesn't depend on it
export function unparse(options: Object): string[] {
  return serializeOverridesIntoCommandLine(options);
}
