// SPDX-FileCopyrightText: 2026 FocusFlow contributors
// SPDX-License-Identifier: AGPL-3.0-only

/// Runs the Check Docs workspace validation tool.
library;

import 'dart:io';

import 'package:analyzer/dart/analysis/utilities.dart';
import 'package:analyzer/dart/ast/ast.dart';
import 'package:analyzer/dart/ast/visitor.dart';
import 'package:analyzer/source/line_info.dart';

/// Generates Dart docs for each package and fails on documentation gaps.
///
/// This tool is intentionally stricter than `dart doc` alone. It checks that
/// tracked Dart files have file-level library docs where appropriate and that
/// every declaration-like API surface has a Dartdoc comment, including private
/// helpers and part files. That keeps internal scheduler behavior readable for
/// future contributors instead of relying on project history or tribal context.
Future<void> main(List<String> arguments) async {
  final skipDartdoc = arguments.contains('--skip-dartdoc');
  final missingFileDocs = await _trackedDartFilesMissingFileDocs();
  if (missingFileDocs.isNotEmpty) {
    stderr.writeln('Dart files missing file-level Dartdoc library headers:');
    for (final path in missingFileDocs) {
      stderr.writeln('- $path');
    }
  }

  final missingDeclarationDocs =
      await _trackedDartFilesMissingDeclarationDocs();
  if (missingDeclarationDocs.isNotEmpty) {
    stderr.writeln('Dart declarations missing Dartdoc comments:');
    for (final missing in missingDeclarationDocs) {
      stderr.writeln('- $missing');
    }
  }

  if (skipDartdoc) {
    if (missingFileDocs.isNotEmpty || missingDeclarationDocs.isNotEmpty) {
      stderr.writeln('Dart documentation quick gate failed.');
      exitCode = 1;
    } else {
      stdout.writeln('Dart documentation quick gate passed.');
    }
    return;
  }

  final outputRoot = Directory(_join(_join('doc', 'api'), 'packages'));
  if (await outputRoot.exists()) {
    await outputRoot.delete(recursive: true);
  }
  await outputRoot.create(recursive: true);

  final packages = Directory('packages')
      .listSync()
      .whereType<Directory>()
      .where((directory) {
    return File(_join(directory.path, 'pubspec.yaml')).existsSync() &&
        Directory(_join(directory.path, 'lib')).existsSync();
  }).toList(growable: false)
    ..sort((left, right) => left.path.compareTo(right.path));

  var failed = missingFileDocs.isNotEmpty || missingDeclarationDocs.isNotEmpty;
  for (final package in packages) {
    final outputPath = _join(outputRoot.absolute.path, _basename(package.path));
    stdout.writeln('> dart doc --output $outputPath (${package.path})');
    final result = await Process.run(
      'dart',
      <String>['doc', '--output', outputPath],
      workingDirectory: package.path,
      runInShell: Platform.isWindows,
    );
    final output = '${result.stdout}${result.stderr}';
    stdout.write(output);
    if (result.exitCode != 0 || _hasWarnings(output)) {
      failed = true;
    }
  }

  if (failed) {
    stderr.writeln('Dart documentation gate failed.');
    exitCode = 1;
  }
}

/// Finds tracked Dart declarations that do not have Dartdoc comments.
///
/// The audit includes classes, mixins, enums, enum values, extensions,
/// extension types, constructors, methods, top-level functions, fields, and
/// top-level variables. Generated files are skipped because their source of
/// truth is the generator configuration, not hand-maintained comments.
Future<List<String>> _trackedDartFilesMissingDeclarationDocs() async {
  final paths = await _trackedDartFiles();
  final missing = <String>[];

  for (final path in paths) {
    final file = File(path);
    final contents = await file.readAsString();
    if (_isGenerated(path, contents)) {
      continue;
    }
    final result = parseString(
      content: contents,
      path: file.absolute.path,
      throwIfDiagnostics: false,
    );
    final visitor = _DeclarationDocVisitor(path, result.unit.lineInfo);
    result.unit.accept(visitor);
    missing.addAll(visitor.missing);
  }

  return missing;
}

/// Returns tracked Dart files in stable path order.
///
/// Git is preferred so local build outputs, generated docs, and editor scratch
/// files do not affect the documentation gate. Container build contexts may omit
/// `.git`, so the tool falls back to a conservative walk over source folders.
Future<List<String>> _trackedDartFiles() async {
  final result = await Process.run(
    'git',
    <String>['ls-files', '*.dart'],
    runInShell: Platform.isWindows,
  );
  if (result.exitCode == 0) {
    return (result.stdout as String)
        .split('\n')
        .where((path) => path.trim().isNotEmpty)
        .toList(growable: false)
      ..sort();
  }

  return _walkDartFiles();
}

/// Walks source folders for Dart files when Git metadata is unavailable.
///
/// This fallback supports Earthly and other copied build contexts while still
/// avoiding generated output and tool caches that are not hand-maintained docs.
List<String> _walkDartFiles() {
  final files = <String>[];
  for (final entity in Directory.current.listSync(recursive: true)) {
    if (entity is! File) {
      continue;
    }
    final path = entity.path.replaceAll('\\', '/').replaceFirst('./', '');
    if (!path.endsWith('.dart') ||
        !_isDartAuditScope(path) ||
        _isIgnoredGeneratedPath(path)) {
      continue;
    }
    files.add(path);
  }
  return files..sort();
}

/// Reports whether [path] belongs to source folders covered by Dartdoc policy.
///
/// The root package, packages, scripts, tools, tests, and Flutter app are the
/// source surfaces where contributors add hand-maintained Dart code.
bool _isDartAuditScope(String path) {
  return path.startsWith('packages/') ||
      path.startsWith('apps/focus_flow_flutter/') ||
      path.startsWith('scripts/') ||
      path.startsWith('tool/') ||
      path.startsWith('test/');
}

/// Reports whether [path] belongs to generated output or local tooling caches.
///
/// The fallback walker skips these paths because they are not source files and
/// usually are not present in Git's tracked file list.
bool _isIgnoredGeneratedPath(String path) {
  return path.startsWith('.dart_tool/') ||
      path.startsWith('.git/') ||
      path.startsWith('build/') ||
      path.startsWith('builds/') ||
      path.startsWith('coverage/') ||
      path.startsWith('doc/api/') ||
      path.contains('/.dart_tool/') ||
      path.contains('/build/') ||
      path.contains('/.plugin_symlinks/');
}

/// Finds tracked Dart files that are missing file-level library docs.
///
/// Part files and generated files are excluded from this specific check because
/// they cannot declare their own `library;` header. Their declarations are still
/// covered by the declaration-level audit above.
Future<List<String>> _trackedDartFilesMissingFileDocs() async {
  final paths = await _trackedDartFiles();
  final missing = <String>[];
  for (final path in paths) {
    final file = File(path);
    final contents = await file.readAsString();
    if (_isGeneratedPart(path, contents)) {
      continue;
    }
    if (!_hasFileLevelLibraryDocs(contents)) {
      missing.add(path);
    }
  }
  return missing;
}

/// Reports whether a file is generated or a library part.
///
/// SPDX headers may appear before `part of`, so the check removes the standard
/// leading SPDX block before identifying part files.
bool _isGeneratedPart(String path, String contents) {
  final source = _stripLeadingSpdxHeader(contents).trimLeft();
  return _isGenerated(path, contents) || source.startsWith('part of ');
}

/// Reports whether a file should be treated as generated source.
///
/// Generated sources are not edited by hand, so enforcing hand-written docs on
/// them would create churn every time the generator runs.
bool _isGenerated(String path, String contents) {
  return path.endsWith('.g.dart') ||
      contents.contains('// GENERATED CODE - DO NOT MODIFY BY HAND');
}

/// Reports whether a Dart file starts with a Dartdoc library header.
///
/// SPDX headers are allowed to precede the docs so files can satisfy both REUSE
/// metadata conventions and Dartdoc's library-level documentation expectations.
bool _hasFileLevelLibraryDocs(String contents) {
  final source = _stripLeadingSpdxHeader(contents);
  return RegExp(
    r'^\s*///[\s\S]*?\nlibrary;',
    multiLine: false,
  ).hasMatch(source);
}

/// Removes the standard leading SPDX comment block from a Dart source string.
///
/// The function deliberately handles only SPDX and copyright line comments at
/// the top of the file; ordinary explanatory comments remain visible to the
/// file-level documentation check.
String _stripLeadingSpdxHeader(String contents) {
  return contents.replaceFirst(
    RegExp(
      r'^(?:(?://\s*SPDX-[^\n]*|//\s*Copyright[^\n]*|//\s*$)\n)*',
      multiLine: false,
    ),
    '',
  );
}

/// Visits Dart declarations and records any declaration without Dartdoc.
///
/// The visitor intentionally ignores local variables and local functions inside
/// method bodies. Those are implementation details where inline comments should
/// remain rare and reserved for unusually complex logic.
class _DeclarationDocVisitor extends RecursiveAstVisitor<void> {
  /// Creates a visitor for one parsed Dart file.
  ///
  /// The [lineInfo] object is reused for every finding so error messages can
  /// point directly at the declaration line that needs documentation.
  _DeclarationDocVisitor(this.path, this.lineInfo);

  /// Repository-relative path for the file currently being audited.
  final String path;

  /// Line lookup table produced by the analyzer parser.
  final LineInfo lineInfo;

  /// Human-readable documentation findings for this file.
  final List<String> missing = <String>[];

  /// Checks class declarations for Dartdoc.
  @override
  void visitClassDeclaration(ClassDeclaration node) {
    _check(node, 'class');
    super.visitClassDeclaration(node);
  }

  /// Checks mixin declarations for Dartdoc.
  @override
  void visitMixinDeclaration(MixinDeclaration node) {
    _check(node, 'mixin');
    super.visitMixinDeclaration(node);
  }

  /// Checks enum declarations for Dartdoc.
  @override
  void visitEnumDeclaration(EnumDeclaration node) {
    _check(node, 'enum');
    super.visitEnumDeclaration(node);
  }

  /// Checks enum constants for Dartdoc.
  @override
  void visitEnumConstantDeclaration(EnumConstantDeclaration node) {
    _check(node, 'enum value');
    super.visitEnumConstantDeclaration(node);
  }

  /// Checks extension declarations for Dartdoc.
  @override
  void visitExtensionDeclaration(ExtensionDeclaration node) {
    _check(node, 'extension');
    super.visitExtensionDeclaration(node);
  }

  /// Checks extension type declarations for Dartdoc.
  @override
  void visitExtensionTypeDeclaration(ExtensionTypeDeclaration node) {
    _check(node, 'extension type');
    super.visitExtensionTypeDeclaration(node);
  }

  /// Checks constructor declarations for Dartdoc.
  @override
  void visitConstructorDeclaration(ConstructorDeclaration node) {
    _check(node, 'constructor');
    super.visitConstructorDeclaration(node);
  }

  /// Checks class, mixin, extension, and enum methods for Dartdoc.
  @override
  void visitMethodDeclaration(MethodDeclaration node) {
    _check(node, 'method');
    super.visitMethodDeclaration(node);
  }

  /// Checks only top-level functions for Dartdoc.
  @override
  void visitFunctionDeclaration(FunctionDeclaration node) {
    if (node.parent is CompilationUnit) {
      _check(node, 'top-level function');
    }
    super.visitFunctionDeclaration(node);
  }

  /// Checks instance and static fields for Dartdoc.
  @override
  void visitFieldDeclaration(FieldDeclaration node) {
    _check(node, 'field');
    super.visitFieldDeclaration(node);
  }

  /// Checks top-level variables for Dartdoc.
  @override
  void visitTopLevelVariableDeclaration(TopLevelVariableDeclaration node) {
    _check(node, 'top-level variable');
    super.visitTopLevelVariableDeclaration(node);
  }

  /// Adds a missing-doc finding for [node] when it has no documentation comment.
  void _check(Declaration node, String kind) {
    if (node.documentationComment != null) {
      return;
    }
    final location = lineInfo.getLocation(node.offset);
    final summary = node.toSource().split('\n').first.trim();
    missing.add('$path:${location.lineNumber}: $kind `$summary`');
  }
}

/// Reports whether `dart doc` produced warning text.
///
/// Dartdoc may return success while still reporting warnings, and this project
/// treats warnings as documentation failures so broken references are caught
/// before they reach CI or generated docs.
bool _hasWarnings(String output) {
  return output.contains(' warning:') ||
      RegExp(r'Found [1-9][0-9]* warnings').hasMatch(output);
}

/// Joins two path segments using the platform separator.
///
/// The helper avoids pulling in another dependency for the small amount of path
/// handling needed by this repository-local script.
String _join(String first, String second) {
  final separator = Platform.pathSeparator;
  return '$first$separator$second';
}

/// Returns the final path segment for a platform-neutral path string.
///
/// Dartdoc output directory names use package basenames, so this helper keeps
/// the script independent of whether Git produced slash or backslash paths.
String _basename(String path) {
  final normalized = path.replaceAll('\\', '/');
  return normalized.substring(normalized.lastIndexOf('/') + 1);
}
