// Copyright (c) 2023, the Dart project authors.  Please see the AUTHORS file
// for details. All rights reserved. Use of this source code is governed by a
// BSD-style license that can be found in the LICENSE file.

import 'dart:convert';
import 'dart:io';

import 'package:path/path.dart' as p;
import 'package:yaml/yaml.dart' as yaml;
import 'package:http/http.dart' as http;

/// Source of truth for linter rules.
final rulesUri = Uri.https(
  'raw.githubusercontent.com',
  'dart-lang/site-www/main/src/data/linter_rules.json',
);

/// Local cache of linter rules from [rulesUrl].
///
/// Relative to package root.
const rulesCacheFilePath = 'tool/rules.json';

/// Generated rules documentation markdown file.
///
/// Relative to package root.
const rulesMarkdownFilePath = 'rules.md';

/// Fetches the [rulesUrl] JSON description of all lints, saves a cached
/// summary of the relevant fields in [rulesCacheFilePath], and
/// updates [rulesMarkdownFilePath].
///
/// Passing any command line argument disables generating documentation,
/// and makes this tool just verify that the doc is up-to-date with the
/// [rulesCacheFilePath]. (Which it always should be, since the two
/// are saved at the same time.)
void main(List<String> args) async {
  final verifyOnly = args.isNotEmpty;

  // Read lint rules.
  final rulesJson = await _fetchRulesJson(verifyOnly: verifyOnly);

  // Read existing generated Markdown documentation.
  final rulesMarkdownFile = _packageRelativeFile(rulesMarkdownFilePath);
  final rulesMarkdownContent = rulesMarkdownFile.readAsStringSync();

  if (verifyOnly) {
    print('Validating that ${rulesMarkdownFile.path} is up-to-date ...');
  } else {
    print('Regenerating ${rulesMarkdownFile.path} ...');
  }

  // Generate new documentation.
  var newRulesMarkdownContent = _updateMarkdown(
    rulesMarkdownContent,
    rulesJson,
  );

  // If no documentation change, all is up-to-date.
  if (newRulesMarkdownContent == rulesMarkdownContent) {
    print('${rulesMarkdownFile.path} is up-to-date.');
    return;
  }

  /// Documentation has changed.
  if (verifyOnly) {
    print(
      '${rulesMarkdownFile.path} is not up-to-date (lint tables need to be '
      'regenerated).',
    );
    print('');
    print("Run 'dart tool/gen_docs.dart' to re-generate.");
    exit(1);
  } else {
    // Save [rulesMarkdownFilePath].
    rulesMarkdownFile.writeAsStringSync(newRulesMarkdownContent);
    print('Wrote ${rulesMarkdownFile.path}.');
  }
}

/// Fetches or load the JSON lint rules.
///
/// If [verifyOnly] is `false`, fetches JSON from [rulesUrl],
/// extracts the needed information, and writes a summary to
/// [rulesCacheFilePath].
///
/// If [verifyOnly] is `true`, only reads the cached data back from
/// [rulesCacheFilePath].
Future<Map<String, Map<String, String>>> _fetchRulesJson({
  required bool verifyOnly,
}) async {
  final rulesJsonFile = _packageRelativeFile(rulesCacheFilePath);
  if (verifyOnly) {
    final rulesJsonText = rulesJsonFile.readAsStringSync();
    return _readJson(rulesJsonText);
  }
  final rulesJsonText = (await http.get(rulesUri)).body;
  final rulesJson = _readJson(rulesJsonText);

  // Re-save [rulesJsonFile] file.
  var newRulesJson = [...rulesJson.values];
  rulesJsonFile.writeAsStringSync(
    JsonEncoder.withIndent('  ').convert(newRulesJson),
  );

  return rulesJson;
}

/// Extracts relevant information from a list of JSON objects.
///
/// For each JSON object, includes only the relevant (string-typed) properties,
/// then creates a map indexed by the `'name'` property of the objects.
Map<String, Map<String, String>> _readJson(String rulesJsonText) {
  /// Relevant keys in the JSON information about lints.
  const relevantKeys = {'name', 'description', 'fixStatus'};
  final rulesJson = jsonDecode(rulesJsonText) as List<dynamic>;
  return {
    for (Map<String, Object?> rule in rulesJson)
      rule['name'] as String: {
        for (var key in relevantKeys) key: rule[key] as String,
      },
  };
}

/// Inserts new Markdown content for both rule sets into [content].
///
/// For both "core" and "recommended" rule sets,
/// replaces the table between the two `<!-- core -->` and the two
/// `<!-- recommended -->` markers with a new table generated from
/// [rulesJson], based on the list of rules in `lib/core.yaml` and
/// `lib/recommended.yaml`.
String _updateMarkdown(
  String content,
  Map<String, Map<String, String>> rulesJson,
) {
  for (var ruleSetName in ['core', 'recommended']) {
    var ruleFile = _packageRelativeFile(p.join('lib', '$ruleSetName.yaml'));
    var ruleSet = _parseRules(ruleFile);

    final rangeDelimiter = '<!-- $ruleSetName -->\n';
    var rangeStart = content.indexOf(rangeDelimiter) + rangeDelimiter.length;
    var rangeEnd = content.indexOf(rangeDelimiter, rangeStart);
    if (rangeEnd < 0) {
      stderr.writeln('Missing "$rangeDelimiter" in $rulesMarkdownFilePath.');
      continue;
    }
    content = content.replaceRange(
      rangeStart,
      rangeEnd,
      _createRuleTable(ruleSet, rulesJson),
    );
  }
  return content;
}

/// Parses analysis options YAML file, and extracts linter rules.
List<String> _parseRules(File yamlFile) {
  var yamlData = yaml.loadYaml(yamlFile.readAsStringSync()) as Map;
  var linterEntry = yamlData['linter'] as Map;
  return List<String>.from(linterEntry['rules'] as List);
}

/// Creates markdown source for a table of lint rules.
String _createRuleTable(
  List<String> rules,
  Map<String, Map<String, String>> lintMeta,
) {
  rules.sort();

  final lines = [
    '| Lint Rules | Description | [Fix][] |',
    '| :--------- | :---------- | ------- |',
    for (var rule in rules) _createRuleTableRow(rule, lintMeta),
  ];

  return '${lines.join('\n')}\n';
}

/// Creates a line containing the markdown table row for a single lint rule.
///
/// Used by [_createRuleTable] for each row in the generated table.
/// The row should have the same number of entires as the table format,
/// and should be on a single line with no newline at the end.
String _createRuleTableRow(
  String rule,
  Map<String, Map<String, String>> lintMeta,
) {
  final ruleMeta = lintMeta[rule];
  if (ruleMeta == null) {
    stderr.writeln("WARNING: Missing rule information for rule: $rule");
  }
  final description = (ruleMeta?['description'] ?? '')
      .replaceAll('\n', ' ')
      .replaceAll(RegExp(r'\s+'), ' ')
      .trim();
  final hasFix = ruleMeta?['fixStatus'] == 'hasFix';
  final fixDesc = hasFix ? '✅' : '';

  return '| [`$rule`](https://dart.dev/lints/$rule) | '
      '$description | $fixDesc |';
}

/// A path relative to the root of this package.
///
/// Works independently of the current working directory.
/// Is based on the location of this script, through [Platform.script].
File _packageRelativeFile(String packagePath) =>
    File(p.join(_packageRoot, packagePath));

/// Cached package root used by [_packageRelative].
final String _packageRoot = _relativePackageRoot();

/// A path to the package root from the current directory.
///
/// If the current directory is inside the package, the returned path is
/// a relative path of a number of `..` segments.
/// If the current directory is outside of the package, the returned path
/// may be absolute.
String _relativePackageRoot() {
  var rootPath = p.dirname(p.dirname(Platform.script.path));
  if (p.isRelative(rootPath)) return rootPath;
  var baseDir = p.current;
  if (rootPath == baseDir) return '';
  if (baseDir.startsWith(rootPath)) {
    var backSteps = <String>[];
    do {
      backSteps.add('..');
      baseDir = p.dirname(baseDir);
    } while (baseDir != rootPath);
    return p.joinAll(backSteps);
  }
  return rootPath;
}
