// Copyright (c) 2014, 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' show json;
import 'dart:io';

import 'resolver.dart';
import 'util.dart';

/// Contains line and function hit information for a single script.
class HitMap {
  /// Constructs a HitMap.
  HitMap([
    Map<int, int>? lineHits,
    this.funcHits,
    this.funcNames,
    this.branchHits,
  ]) : lineHits = lineHits ?? {};

  /// Constructs an empty hitmap, optionally with function and branch coverage
  /// tables.
  HitMap.empty({bool functionCoverage = false, bool branchCoverage = false})
      : this(
            null,
            functionCoverage ? <int, int>{} : null,
            functionCoverage ? <int, String>{} : null,
            branchCoverage ? <int, int>{} : null);

  /// Map from line to hit count for that line.
  final Map<int, int> lineHits;

  /// Map from the first line of each function, to the hit count for that
  /// function. Null if function coverage info was not gathered.
  Map<int, int>? funcHits;

  /// Map from the first line of each function, to the function name. Null if
  /// function coverage info was not gathered.
  Map<int, String>? funcNames;

  /// Map from branch line, to the hit count for that branch. Null if branch
  /// coverage info was not gathered.
  Map<int, int>? branchHits;

  /// Creates a single hitmap from a raw json object.
  ///
  /// Note that when [checkIgnoredLines] is `true` all files will be
  /// read to get ignore comments. This will add some overhead.
  /// To combat this when calling this function multiple times from the
  /// same source (e.g. test runs of different files) a cache is taken
  /// via [ignoredLinesInFilesCache]. If this cache contains the parsed
  /// data for the specific file already, the file will not be read and
  /// parsed again.
  ///
  /// Throws away all entries that are not resolvable.
  static Map<String, HitMap> parseJsonSync(
    List<Map<String, dynamic>> jsonResult, {
    required bool checkIgnoredLines,
    required Map<String, List<List<int>>?> ignoredLinesInFilesCache,
    required Resolver resolver,
  }) {
    // Map of source file to map of line to hit count for that line.
    final globalHitMap = <String, HitMap>{};

    for (var e in jsonResult) {
      final source = e['source'] as String?;
      if (source == null) {
        // Couldn't resolve import, so skip this entry.
        continue;
      }

      void addToMap(Map<int, int> map, int line, int count) {
        final oldCount = map.putIfAbsent(line, () => 0);
        map[line] = count + oldCount;
      }

      void fillHitMap(List hits, Map<int, int> hitMap) {
        // Ignore line annotations require hits to be sorted.
        hits = _sortHits(hits);
        // hits is a flat array of the following format:
        // [ <line|linerange>, <hitcount>,...]
        // line: number.
        // linerange: '<line>-<line>'.
        for (var i = 0; i < hits.length; i += 2) {
          final k = hits[i];
          if (k is int) {
            // Single line.
            addToMap(hitMap, k, hits[i + 1] as int);
          } else if (k is String) {
            // Linerange. We expand line ranges to actual lines at this point.
            final splitPos = k.indexOf('-');
            final start = int.parse(k.substring(0, splitPos));
            final end = int.parse(k.substring(splitPos + 1));
            for (var j = start; j <= end; j++) {
              addToMap(hitMap, j, hits[i + 1] as int);
            }
          } else {
            throw StateError('Expected value of type int or String');
          }
        }
      }

      final sourceHitMap = globalHitMap.putIfAbsent(source, HitMap.new);
      fillHitMap(e['hits'] as List, sourceHitMap.lineHits);
      if (e.containsKey('funcHits')) {
        sourceHitMap.funcHits ??= <int, int>{};
        fillHitMap(e['funcHits'] as List, sourceHitMap.funcHits!);
      }
      if (e.containsKey('funcNames')) {
        sourceHitMap.funcNames ??= <int, String>{};
        final funcNames = e['funcNames'] as List;
        for (var i = 0; i < funcNames.length; i += 2) {
          sourceHitMap.funcNames![funcNames[i] as int] =
              funcNames[i + 1] as String;
        }
      }
      if (e.containsKey('branchHits')) {
        sourceHitMap.branchHits ??= <int, int>{};
        fillHitMap(e['branchHits'] as List, sourceHitMap.branchHits!);
      }
    }
    return checkIgnoredLines
        ? globalHitMap.filterIgnored(
            ignoredLinesInFilesCache: ignoredLinesInFilesCache,
            resolver: resolver,
          )
        : globalHitMap;
  }

  /// Creates a single hitmap from a raw json object.
  ///
  /// Throws away all entries that are not resolvable.
  static Future<Map<String, HitMap>> parseJson(
    List<Map<String, dynamic>> jsonResult, {
    bool checkIgnoredLines = false,
    @Deprecated('Use packagePath') String? packagesPath,
    String? packagePath,
  }) async {
    final resolver = await Resolver.create(
        packagesPath: packagesPath, packagePath: packagePath);
    return parseJsonSync(jsonResult,
        checkIgnoredLines: checkIgnoredLines,
        ignoredLinesInFilesCache: {},
        resolver: resolver);
  }

  /// Generates a merged hitmap from a set of coverage JSON files.
  static Future<Map<String, HitMap>> parseFiles(
    Iterable<File> files, {
    bool checkIgnoredLines = false,
    @Deprecated('Use packagePath') String? packagesPath,
    String? packagePath,
  }) async {
    final globalHitmap = <String, HitMap>{};
    for (var file in files) {
      final contents = file.readAsStringSync();
      final jsonMap = json.decode(contents) as Map<String, dynamic>;
      if (jsonMap.containsKey('coverage')) {
        final jsonResult = jsonMap['coverage'] as List;
        globalHitmap.merge(await HitMap.parseJson(
          jsonResult.cast<Map<String, dynamic>>(),
          checkIgnoredLines: checkIgnoredLines,
          // ignore: deprecated_member_use_from_same_package
          packagesPath: packagesPath,
          packagePath: packagePath,
        ));
      }
    }
    return globalHitmap;
  }
}

extension FileHitMaps on Map<String, HitMap> {
  /// Merges [newMap] into this one.
  void merge(Map<String, HitMap> newMap) {
    newMap.forEach((file, v) {
      final fileResult = this[file];
      if (fileResult != null) {
        _mergeHitCounts(v.lineHits, fileResult.lineHits);
        if (v.funcHits != null) {
          fileResult.funcHits ??= <int, int>{};
          _mergeHitCounts(v.funcHits!, fileResult.funcHits!);
        }
        if (v.funcNames != null) {
          fileResult.funcNames ??= <int, String>{};
          v.funcNames?.forEach((line, name) {
            fileResult.funcNames![line] = name;
          });
        }
        if (v.branchHits != null) {
          fileResult.branchHits ??= <int, int>{};
          _mergeHitCounts(v.branchHits!, fileResult.branchHits!);
        }
      } else {
        this[file] = v;
      }
    });
  }

  static void _mergeHitCounts(Map<int, int> src, Map<int, int> dest) {
    src.forEach((line, count) {
      final lineFileResult = dest[line];
      if (lineFileResult == null) {
        dest[line] = count;
      } else {
        dest[line] = lineFileResult + count;
      }
    });
  }

  /// Filters out lines that are ignored by ignore comments.
  Map<String, HitMap> filterIgnored({
    required Map<String, List<List<int>>?> ignoredLinesInFilesCache,
    required Resolver resolver,
  }) {
    final loader = Loader();
    final newHitMaps = <String, HitMap>{};
    for (final MapEntry(key: source, value: hitMap) in entries) {
      final ignoredLinesList = ignoredLinesInFilesCache.putIfAbsent(source, () {
        final path = resolver.resolve(source);
        if (path == null) return <List<int>>[];
        return getIgnoredLines(path, loader.loadSync(path));
      });
      // Null here means that the whole file is ignored.
      if (ignoredLinesList == null) continue;

      Map<int, int>? filterHits(Map<int, int>? hits) => hits == null
          ? null
          : {
              for (final MapEntry(key: line, value: count) in hits.entries)
                if (!ignoredLinesList.ignoredContains(line)) line: count,
            };

      newHitMaps[source] = HitMap(
        filterHits(hitMap.lineHits),
        filterHits(hitMap.funcHits),
        hitMap.funcNames,
        filterHits(hitMap.branchHits),
      );
    }
    return newHitMaps;
  }
}

/// Class containing information about a coverage hit.
class _HitInfo {
  _HitInfo(this.firstLine, this.hitRange, this.hitCount);

  /// The line number of the first line of this hit range.
  final int firstLine;

  /// A hit range is either a number (1 line) or a String of the form
  /// "start-end" (multi-line range).
  final dynamic hitRange;

  /// How many times this hit range was executed.
  final int hitCount;
}

/// Creates a single hitmap from a raw json object.
///
/// Throws away all entries that are not resolvable.
@Deprecated('Migrate to HitMap.parseJson')
Future<Map<String, Map<int, int>>> createHitmap(
  List<Map<String, dynamic>> jsonResult, {
  bool checkIgnoredLines = false,
  @Deprecated('Use packagePath') String? packagesPath,
  String? packagePath,
}) async {
  final result = await HitMap.parseJson(
    jsonResult,
    checkIgnoredLines: checkIgnoredLines,
    packagesPath: packagesPath,
    packagePath: packagePath,
  );
  return result.map((key, value) => MapEntry(key, value.lineHits));
}

/// Merges [newMap] into [result].
@Deprecated('Migrate to FileHitMaps.merge')
void mergeHitmaps(
    Map<String, Map<int, int>> newMap, Map<String, Map<int, int>> result) {
  newMap.forEach((file, v) {
    final fileResult = result[file];
    if (fileResult != null) {
      v.forEach((line, count) {
        final lineFileResult = fileResult[line];
        if (lineFileResult == null) {
          fileResult[line] = count;
        } else {
          fileResult[line] = lineFileResult + count;
        }
      });
    } else {
      result[file] = v;
    }
  });
}

/// Generates a merged hitmap from a set of coverage JSON files.
@Deprecated('Migrate to HitMap.parseFiles')
Future<Map<String, Map<int, int>>> parseCoverage(
  Iterable<File> files,
  int _, {
  bool checkIgnoredLines = false,
  @Deprecated('Use packagePath') String? packagesPath,
  String? packagePath,
}) async {
  final result = await HitMap.parseFiles(files,
      checkIgnoredLines: checkIgnoredLines,
      packagesPath: packagesPath,
      packagePath: packagePath);
  return result.map((key, value) => MapEntry(key, value.lineHits));
}

/// Returns a JSON hit map backward-compatible with pre-1.16.0 SDKs.
@Deprecated('Will be removed in 2.0.0')
Map<String, dynamic> toScriptCoverageJson(Uri scriptUri, Map<int, int> hitMap) {
  return hitmapToJson(HitMap(hitMap), scriptUri);
}

List<T> _flattenMap<T>(Map map) {
  final kvs = <T>[];
  map.forEach((k, v) {
    kvs.add(k as T);
    kvs.add(v as T);
  });
  return kvs;
}

/// Returns a JSON hit map backward-compatible with pre-1.16.0 SDKs.
Map<String, dynamic> hitmapToJson(HitMap hitmap, Uri scriptUri) =>
    <String, dynamic>{
      'source': '$scriptUri',
      'script': {
        'type': '@Script',
        'fixedId': true,
        'id':
            'libraries/1/scripts/${Uri.encodeComponent(scriptUri.toString())}',
        'uri': '$scriptUri',
        '_kind': 'library',
      },
      'hits': _flattenMap<int>(hitmap.lineHits),
      if (hitmap.funcHits != null)
        'funcHits': _flattenMap<int>(hitmap.funcHits!),
      if (hitmap.funcNames != null)
        'funcNames': _flattenMap<dynamic>(hitmap.funcNames!),
      if (hitmap.branchHits != null)
        'branchHits': _flattenMap<int>(hitmap.branchHits!),
    };

/// Sorts the hits array based on the line numbers.
List _sortHits(List hits) {
  final structuredHits = <_HitInfo>[];
  for (var i = 0; i < hits.length - 1; i += 2) {
    final lineOrLineRange = hits[i];
    final firstLineInRange = lineOrLineRange is int
        ? lineOrLineRange
        : int.parse((lineOrLineRange as String).split('-')[0]);
    structuredHits.add(_HitInfo(firstLineInRange, hits[i], hits[i + 1] as int));
  }
  structuredHits.sort((a, b) => a.firstLine.compareTo(b.firstLine));
  return structuredHits
      .map((item) => [item.hitRange, item.hitCount])
      .expand((item) => item)
      .toList();
}
