import path from "node:path";
import ts from "typescript";

if (process.argv.length > 2) {
  console.error("Usage: node scripts/lint/check-import-cycles.mjs");
  process.exit(1);
}

const cwd = process.cwd();
const configPath = ts.findConfigFile(cwd, ts.sys.fileExists, "tsconfig.json");
if (!configPath) {
  console.error("Could not find tsconfig.json.");
  process.exit(1);
}

const configFile = ts.readConfigFile(configPath, ts.sys.readFile);
if (configFile.error) {
  reportDiagnostic(configFile.error);
  process.exit(1);
}

const parsedConfig = ts.parseJsonConfigFileContent(configFile.config, ts.sys, path.dirname(configPath));
if (parsedConfig.errors.length > 0) {
  for (const diagnostic of parsedConfig.errors) reportDiagnostic(diagnostic);
  process.exit(1);
}

const sourceFiles = parsedConfig.fileNames.map((file) => path.resolve(file)).filter((file) => isCheckedSourceFile(relativePath(file)));
const sourceFileSet = new Set(sourceFiles);
const compilerHost = ts.createCompilerHost(parsedConfig.options, true);
const moduleResolutionCache = ts.createModuleResolutionCache(cwd, (file) => file, parsedConfig.options);
const graph = new Map(sourceFiles.map((file) => [relativePath(file), new Set()]));

for (const sourceFile of sourceFiles) {
  const sourceText = ts.sys.readFile(sourceFile);
  if (sourceText === undefined) continue;

  const parsedSource = ts.createSourceFile(sourceFile, sourceText, parsedConfig.options.target ?? ts.ScriptTarget.ES2022, true);
  const from = relativePath(sourceFile);

  for (const edge of importEdges(parsedSource)) {
    const resolved = ts.resolveModuleName(
      edge.specifier,
      sourceFile,
      parsedConfig.options,
      compilerHost,
      moduleResolutionCache,
    ).resolvedModule;
    if (!resolved) continue;

    const resolvedFile = path.resolve(resolved.resolvedFileName);
    if (!sourceFileSet.has(resolvedFile)) continue;

    graph.get(from)?.add(relativePath(resolvedFile));
  }
}

const cycleComponents = stronglyConnectedComponents(graph)
  .filter((component) => component.length > 1 || hasSelfDependency(graph, component[0]))
  .sort((left, right) => left[0].localeCompare(right[0]));

if (cycleComponents.length === 0) {
  process.exit(0);
}

console.error(`Import cycle check failed: found ${cycleComponents.length} cycle${cycleComponents.length === 1 ? "" : "s"}.`);
for (const [index, component] of cycleComponents.entries()) {
  const cycle = sampleCycle(graph, component);
  console.error(`${index + 1}. ${cycle.join(" -> ")}`);
}
process.exit(1);

function importEdges(sourceFile) {
  const edges = [];

  for (const statement of sourceFile.statements) {
    if (ts.isImportDeclaration(statement) && hasRuntimeImportEdge(statement) && stringLiteralText(statement.moduleSpecifier)) {
      edges.push({
        specifier: stringLiteralText(statement.moduleSpecifier),
      });
    } else if (
      ts.isExportDeclaration(statement) &&
      hasRuntimeExportEdge(statement) &&
      statement.moduleSpecifier &&
      stringLiteralText(statement.moduleSpecifier)
    ) {
      edges.push({
        specifier: stringLiteralText(statement.moduleSpecifier),
      });
    }
  }

  return edges;
}

function hasRuntimeImportEdge(statement) {
  const importClause = statement.importClause;
  if (!importClause) return true;
  if (importClause.isTypeOnly) return false;
  if (importClause.name) return true;

  const namedBindings = importClause.namedBindings;
  if (!namedBindings) return false;
  if (ts.isNamespaceImport(namedBindings)) return true;
  return namedBindings.elements.some((element) => !element.isTypeOnly);
}

function hasRuntimeExportEdge(statement) {
  if (statement.isTypeOnly) return false;

  const exportClause = statement.exportClause;
  if (!exportClause) return true;
  if (ts.isNamespaceExport(exportClause)) return true;
  return exportClause.elements.some((element) => !element.isTypeOnly);
}

function stringLiteralText(node) {
  return ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node) ? node.text : null;
}

function stronglyConnectedComponents(graph) {
  let nextIndex = 0;
  const stack = [];
  const onStack = new Set();
  const indices = new Map();
  const lowLinks = new Map();
  const components = [];

  for (const node of graph.keys()) {
    if (!indices.has(node)) visit(node);
  }

  return components.map((component) => component.sort());

  function visit(node) {
    indices.set(node, nextIndex);
    lowLinks.set(node, nextIndex);
    nextIndex += 1;
    stack.push(node);
    onStack.add(node);

    for (const dependency of graph.get(node) ?? []) {
      if (!indices.has(dependency)) {
        visit(dependency);
        lowLinks.set(node, Math.min(lowLinks.get(node), lowLinks.get(dependency)));
      } else if (onStack.has(dependency)) {
        lowLinks.set(node, Math.min(lowLinks.get(node), indices.get(dependency)));
      }
    }

    if (lowLinks.get(node) !== indices.get(node)) return;

    const component = [];
    let item;
    do {
      item = stack.pop();
      onStack.delete(item);
      component.push(item);
    } while (item !== node);
    components.push(component);
  }
}

function sampleCycle(graph, component) {
  const componentSet = new Set(component);

  for (const start of component) {
    if (graph.get(start)?.has(start)) return [start, start];

    for (const dependency of graph.get(start) ?? []) {
      if (!componentSet.has(dependency)) continue;
      const pathToStart = findPath(graph, componentSet, dependency, start, new Set([start]));
      if (pathToStart) return [start, ...pathToStart];
    }
  }

  return component;
}

function findPath(graph, allowedNodes, current, target, seen) {
  if (current === target) return [current];
  if (seen.has(current)) return null;
  seen.add(current);

  for (const dependency of graph.get(current) ?? []) {
    if (!allowedNodes.has(dependency)) continue;
    const path = findPath(graph, allowedNodes, dependency, target, seen);
    if (path) return [current, ...path];
  }

  return null;
}

function hasSelfDependency(graph, node) {
  return Boolean(node && graph.get(node)?.has(node));
}

function isCheckedSourceFile(file) {
  return (
    file.startsWith("src/") &&
    !file.startsWith("src/generated/") &&
    (file.endsWith(".ts") || file.endsWith(".tsx")) &&
    !file.endsWith(".d.ts")
  );
}

function relativePath(file) {
  return path.relative(cwd, file).split(path.sep).join("/");
}

function reportDiagnostic(diagnostic) {
  console.error(ts.flattenDiagnosticMessageText(diagnostic.messageText, "\n"));
}
