import { promises as fs } from 'fs';
import { compile } from 'handlebars';
import * as path from 'path';
import ts from 'typescript';
import * as ux from './ux';

type PackageError = { code: string; documentation: string };
type PackageErrors = { package: string; errors: PackageError[] };

const MONGOSH_ERRORS_DOC_TAG = 'mongoshErrors';

(async function () {
  const pathToPackages = path.resolve(process.argv[process.argv.length - 3]);
  const pathToMarkdownOutput = path.resolve(
    process.argv[process.argv.length - 2]
  );
  const pathToRestructuredOutput = path.resolve(
    process.argv[process.argv.length - 1]
  );

  if (!pathToPackages || !(await isDirectory(pathToPackages))) {
    ux.fatal('Could not find given packages directory:', pathToPackages);
    return;
  }

  const packageErrors: PackageErrors[] = [];
  const seenCodes = new Map<String, PackageError>();
  let hasDuplicates = false;

  const packages = await collectPackages(pathToPackages);
  for (const packagePath of packages) {
    const dirName = path.basename(packagePath);
    ux.info(`Processing ${dirName}...`);
    const pe = await processPackage(packagePath);
    if (pe) {
      packageErrors.push(pe);
      ux.note(`Found ${pe.errors.length} mongosh errors.\n`);

      for (const e of pe.errors) {
        if (seenCodes.has(e.code)) {
          ux.error(
            `Duplicate code: ${e.code}\n`,
            `\tExisting documentation: ${
              seenCodes.get(e.code)?.documentation
            }\n`,
            `\tNew documentation:      ${e.documentation}\n`
          );
          hasDuplicates = true;
        } else {
          seenCodes.set(e.code, e);
        }
      }
    } else {
      ux.quiet('No mongosh errors found.\n');
    }
  }

  if (hasDuplicates) {
    ux.fatal('There were duplicate error codes, check output.');
    return;
  }

  await renderErrorOverviewMarkdown(pathToMarkdownOutput, packageErrors);
  await renderErrorOverviewRestructured(
    pathToRestructuredOutput,
    packageErrors
  );

  ux.success('👏👏👏👏');
  ux.success('Wrote generated overview page:');
  ux.success(`  -> markdown:     ${pathToMarkdownOutput}`);
  ux.success(`  -> restructured: ${pathToRestructuredOutput}`);
  ux.success('👏👏👏👏');
})().catch((err) =>
  process.nextTick(() => {
    throw err;
  })
);

async function isDirectory(path: string): Promise<boolean> {
  try {
    const stat = await fs.lstat(path);
    return stat.isDirectory();
  } catch (e: any) {
    return false;
  }
}

async function isFile(path: string): Promise<boolean> {
  try {
    const stat = await fs.lstat(path);
    return stat.isFile();
  } catch (e: any) {
    return false;
  }
}

async function collectPackages(pathToPackages: string): Promise<string[]> {
  const dirs = await fs.readdir(pathToPackages);
  const packages = await Promise.all(
    dirs.map(async (dir) => {
      const packageJsonPath = path.resolve(pathToPackages, dir, 'package.json');
      const tsconfigPath = path.resolve(pathToPackages, dir, 'tsconfig.json');
      if ((await isFile(tsconfigPath)) && (await isFile(packageJsonPath))) {
        return path.resolve(pathToPackages, dir);
      }
      return undefined;
    })
  );
  return packages.filter((d) => !!d) as string[];
}

async function processPackage(
  pathToPackage: string
): Promise<PackageErrors | undefined> {
  const packageJsonContent = await fs.readFile(
    path.resolve(pathToPackage, 'package.json'),
    { encoding: 'utf-8' }
  );
  const packageName = (JSON.parse(packageJsonContent) as any).name;

  const tsProgram = await createTsProgram(pathToPackage);
  if (!tsProgram) {
    return undefined;
  }

  const errors: PackageError[] = await extractErrors(pathToPackage, tsProgram);
  if (!errors || !errors.length) {
    return undefined;
  }

  return {
    package: packageName,
    errors,
  };
}

// eslint-disable-next-line @typescript-eslint/require-await
async function createTsProgram(
  pathToPackage: string
): Promise<ts.Program | undefined> {
  const tsconfigPath = ts.findConfigFile(pathToPackage, ts.sys.fileExists);
  if (!tsconfigPath) {
    ux.error(`Could not locate tsconfig.json in ${pathToPackage}`);
    return undefined;
  }

  const tsconfigContent = ts.readJsonConfigFile(tsconfigPath, ts.sys.readFile);
  const tsconfig = ts.parseJsonConfigFileContent(
    tsconfigContent,
    ts.sys,
    path.resolve(pathToPackage)
  );

  return ts.createProgram({
    options: tsconfig.options,
    rootNames: tsconfig.fileNames,
  });
}

// eslint-disable-next-line @typescript-eslint/require-await
async function extractErrors(
  pathToPackage: string,
  program: ts.Program
): Promise<PackageError[]> {
  const errors: PackageError[] = [];

  const checker = program.getTypeChecker();
  program
    .getSourceFiles()
    .filter((sf) => isRelevantSourceFileInPackage(pathToPackage, sf))
    .forEach((sf) => ts.forEachChild(sf, visit));

  function visit(node: ts.Node): void {
    const enumData = tryExtractMongoshErrorsEnumDeclaration(checker, node);
    if (!enumData) {
      return;
    }
    const { enumName, enumDeclaration } = enumData;

    enumDeclaration.members.forEach((m) => {
      const memberName = m.name.getText();
      if (!m.initializer || !ts.isStringLiteral(m.initializer)) {
        ux.error(
          `Enum value ${enumName}.${memberName} must have a string literal as initializer`
        );
        return;
      }

      const memberSymbol = checker.getSymbolAtLocation(m.name);
      const memberDoc = ts.displayPartsToString(
        memberSymbol?.getDocumentationComment(checker) || []
      );

      const memberValue = m.initializer.text;
      errors.push({
        code: memberValue,
        documentation: memberDoc,
      });
    });
  }

  return errors;
}

function isRelevantSourceFileInPackage(
  pathToPackage: string,
  sf: ts.SourceFile
): boolean {
  if (sf.isDeclarationFile) {
    return false;
  }

  return path.resolve(sf.fileName).startsWith(path.resolve(pathToPackage));
}

function tryExtractMongoshErrorsEnumDeclaration(
  checker: ts.TypeChecker,
  node: ts.Node
): { enumName: string; enumDeclaration: ts.EnumDeclaration } | undefined {
  if (!ts.isEnumDeclaration(node)) {
    return undefined;
  }

  const enumSymbol = checker.getSymbolAtLocation(node.name);
  if (!enumSymbol) {
    ux.warning(
      'Found enum but could not correlate with symbol:',
      node.name.getText()
    );
    return undefined;
  }

  const docTags = enumSymbol.getJsDocTags();
  if (!docTags.find((t) => t.name === MONGOSH_ERRORS_DOC_TAG)) {
    return undefined;
  }

  const enumName = enumSymbol.getName();
  const enumDeclaration = enumSymbol.valueDeclaration as ts.EnumDeclaration;
  return { enumName, enumDeclaration };
}

async function renderErrorOverviewMarkdown(
  outputPath: string,
  packageErrors: PackageErrors[]
): Promise<void> {
  const templateContent = await fs.readFile(
    path.resolve(__dirname, 'error-overview.tmpl.md'),
    { encoding: 'utf-8' }
  );

  const template = compile(templateContent);
  const output = template({
    packages: packageErrors,
  });

  await fs.writeFile(outputPath, output, { encoding: 'utf-8' });
}

async function renderErrorOverviewRestructured(
  outputPath: string,
  packageErrors: PackageErrors[]
): Promise<void> {
  const restructuredErrors = packageErrors.map((pe) => {
    return {
      package: pe.package,
      packageHeadlineSeparator: '-'.repeat(pe.package.length),
      errors: pe.errors.map(convertMarkdownToRestructured),
    };
  });

  const templateContent = await fs.readFile(
    path.resolve(__dirname, 'error-overview.tmpl.rst'),
    { encoding: 'utf-8' }
  );

  const template = compile(templateContent);
  const output = template({
    packages: restructuredErrors,
  });

  await fs.writeFile(outputPath, output, { encoding: 'utf-8' });
}

function convertMarkdownToRestructured(
  error: PackageError
): PackageError & { codeHeadlineSeparator: string } {
  // every odd element is _inside_ a code block (```)
  const docWithCodeBlocks = error.documentation.split('```');
  for (let i = 0; i < docWithCodeBlocks.length; i++) {
    const text = docWithCodeBlocks[i];
    let restructured = '';
    if (i % 2 === 1) {
      // we have a code block - prefix and indent
      const codeLines = text.split('\n');
      restructured += '.. code-block:: ' + codeLines.shift() + '\n\n';
      restructured += codeLines.map((l) => '   ' + l).join('\n');
    } else {
      const inlineCode = /`([^`]*)`/g;
      restructured = text.replace(inlineCode, (_, code) => '``' + code + '``');
    }
    docWithCodeBlocks[i] = restructured;
  }

  return {
    code: error.code,
    codeHeadlineSeparator: '~'.repeat(error.code.length + 4),
    documentation: docWithCodeBlocks.join('\n'),
  };
}
