// Copyright 2020 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only

// This file doesn't check the format of license files, just the end year. See
//   `license_comments_test.ts` for those checks, which are meant to be run more often.

import assert from 'node:assert';
import * as readline from 'node:readline';
import * as path from 'node:path';
import * as fs from 'node:fs';
import { promisify, styleText } from 'node:util';
import * as childProcess from 'node:child_process';
import pMap from 'p-map';
import micromatch from 'micromatch';

const exec = promisify(childProcess.exec);

const rootPath = path.join(__dirname, '..', '..', '..');

const EXTENSIONS_TO_CHECK = new Set([
  '.gitattributes',
  '.gitignore',
  '.nvmrc',
  '.prettierignore',
  '.sh',
  '.snyk',
  '.yml',
  '.js',
  '.scss',
  '.ts',
  '.tsx',
  '.html',
  '.md',
  '.plist',
]);
const FILES_TO_IGNORE = [
  '.changeset/*.md',
  'packages/*/CHANGELOG.md',
  '.github/ISSUE_TEMPLATE/bug_report.md',
  '.github/PULL_REQUEST_TEMPLATE.md',
  '.smartling-source.sh',
  'packages/mute-state-change/dist/acknowledgments.md',
  'packages/lame/dist/acknowledgments.md',
  'sticker-creator/src/util/protos.d.ts',
  'sticker-creator/src/util/protos.js',
];

// This is not technically the real extension.
export function getExtension(file: string): string {
  if (file.startsWith('.')) {
    return getExtension(`x.${file}`);
  }
  return path.extname(file);
}

export async function forEachRelevantFile(
  fn: (_: string) => Promise<unknown>
): Promise<void> {
  const gitFiles = (
    await exec('git ls-files', { cwd: rootPath, env: {} })
  ).stdout
    .split(/\n/g)
    .map(line => line.trim())
    .filter(Boolean)
    .map(file => path.join(rootPath, file));

  await pMap(
    gitFiles,
    async (file: string) => {
      const repoPath = path.relative(rootPath, file);

      if (micromatch.isMatch(repoPath, FILES_TO_IGNORE)) {
        return;
      }

      const extension = getExtension(file);
      if (!EXTENSIONS_TO_CHECK.has(extension)) {
        return;
      }

      await fn(file);
    },
    // Without this, we may run into "too many open files" errors.
    { concurrency: 100 }
  );
}

export function readFirstLines(
  file: string,
  count: number
): Promise<Array<string>> {
  return new Promise(resolve => {
    const lines: Array<string> = [];

    const lineReader = readline.createInterface({
      input: fs.createReadStream(file),
    });
    lineReader.on('line', line => {
      // `lineReader.close()` does not guarantee 'line' won't be emitted again
      if (lines.length < count) {
        lines.push(line);
      }
      if (lines.length >= count) {
        lineReader.close();
      }
    });
    lineReader.on('close', () => {
      resolve(lines);
    });
  });
}

async function getCommitFileWasAdded(
  file: string
): Promise<{ commitYear: number; commitHash: string }> {
  const logLine = (
    await new Promise<string>((resolve, reject) => {
      let result = '';
      // We use the more verbose `spawn` to avoid command injection, in case the filename
      //   has strange characters.
      const gitLog = childProcess.spawn(
        'git',
        [
          // From: https://stackoverflow.com/questions/11533199/how-to-find-the-commit-in-which-a-given-file-was-added
          'log',
          '-1', // limit number of lines to return to 1
          '--diff-filter=A', // select only files that are added (A)
          '--follow', // continue listing the history of a file beyond renames
          '--find-renames=40%', // consider a delete/add pair to be a rename if less than 40% of the file has changed (default 50%)
          '--format=%as %h', // display commit as date YYYY-MM-DD
          file,
        ],
        {
          cwd: rootPath,
          env: { PATH: process.env.PATH },
        }
      );
      gitLog.stdout?.on('data', data => {
        result += data.toString('utf8');
      });
      gitLog.on('close', code => {
        if (code === 0) {
          resolve(result);
        } else {
          reject(new Error(`git log failed with exit code ${code}`));
        }
      });
    })
  ).trim();

  const [dateString, commitHash] = logLine.split(' ');

  const commitYear = new Date(dateString).getFullYear();
  assert(!Number.isNaN(commitYear), `Could not read commit year for ${file}`);
  assert(commitHash, 'Missing commitHash');
  return { commitYear, commitHash };
}

type Failure = {
  file: string;
  warnings: Array<string>;
};

function indent(text: string) {
  return text
    .split('\n')
    .map(line => `  ${line}`)
    .join('\n');
}

async function main() {
  const currentYear = new Date().getFullYear();
  const failures: Array<Failure> = [];

  await forEachRelevantFile(async file => {
    let lines: Array<string>;
    let firstLine: string | void;
    let secondLine: string | void;

    if (getExtension(file) === '.sh') {
      lines = await readFirstLines(file, 3);
      [, firstLine, secondLine] = lines;
    } else {
      lines = await readFirstLines(file, 2);
      [firstLine, secondLine] = lines;
    }

    const warnings = [];

    if (
      firstLine == null ||
      !/Copyright \d{4} Signal Messenger, LLC/.test(firstLine)
    ) {
      const commit = await getCommitFileWasAdded(file);
      warnings.push(
        styleText('red', 'Missing/Incorrect copyright line'),
        indent(
          styleText(
            'green',
            `Expected: "Copyright ${commit.commitYear} Signal Messenger, LLC"`
          )
        ),
        // oxlint-disable-next-line typescript/restrict-template-expressions
        indent(styleText('yellow', `Actual: "${firstLine}"`)),
        indent(
          styleText(
            ['italic', 'dim'],
            `Tip: Looks like this file was added in ${commit.commitHash} in ${commit.commitYear}`
          )
        ),
        indent(
          styleText(
            ['italic', 'dim'],
            `Tip: You can also use the current year (${currentYear})`
          )
        )
      );
    } else if (/\d{4}-\d{4}/.test(firstLine)) {
      warnings.push(
        styleText('red', 'Copyright should not include end year'),
        indent(styleText('yellow', `Actual: "${firstLine}"`))
      );
    }

    if (!secondLine?.includes('SPDX-License-Identifier: AGPL-3.0-only')) {
      warnings.push(
        styleText('red', 'Missing/incorrect license line'),
        indent(
          styleText(
            'green',
            'Expected: "SPDX-License-Identifier: AGPL-3.0-only"'
          )
        ),
        // oxlint-disable-next-line typescript/restrict-template-expressions
        indent(styleText('yellow', `Actual: "${secondLine}"`))
      );
    }

    if (warnings.length) {
      failures.push({ file, warnings });
    }
  });

  const failed = failures.length > 0;

  if (failed) {
    console.log();
    console.log(
      styleText(
        ['magenta', 'bold'],
        'Some files are missing/contain incorrect copyrights/licenses:'
      )
    );
    console.log();
    for (const failure of failures) {
      console.log(styleText('bold', `${failure.file}:`));
      console.log(indent(failure.warnings.join('\n')));
      console.log();
    }

    console.log(
      styleText(['magenta', 'bold'], '`pnpm lint-license-comments` failed')
    );
    console.log();

    process.exit(1);
  }
}

// Note: this check will fail if we switch to ES modules. See
//  <https://stackoverflow.com/a/60309682>.
if (require.main === module) {
  // oxlint-disable-next-line promise/prefer-await-to-then
  main().catch(err => {
    console.error(err);
    process.exit(1);
  });
}
