// Copyright (c) 2020, 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 'package:analyzer/src/utilities/fuzzy_matcher.dart';
import 'package:test/test.dart';
import 'package:test_reflective_loader/test_reflective_loader.dart';

void main() {
  defineReflectiveSuite(() {
    defineReflectiveTests(FuzzyMapTest);
    defineReflectiveTests(FuzzyMatcherTest);
    defineReflectiveTests(FuzzyScorerTest);
    defineReflectiveTests(ScoringFunctionTest);
  });
}

/// Surrounds all matching ranges with brackets.
String highlightMatches(String str, FuzzyMatcher matcher) {
  var matches = matcher.getMatchedRanges();
  expect(matches.length % 2, 0);
  for (var i = 1; i < matches.length; i++) {
    expect(matches[i], isNot(lessThan(matches[i - 1])));
  }

  var index = 0;
  var result = '';
  for (var i = 0; i < matches.length - 1; i += 2) {
    result +=
        '${str.substring(index, matches[i])}[${str.substring(matches[i], matches[i + 1])}]';
    index = matches[i + 1];
  }
  return result + str.substring(index);
}

@reflectiveTest
class FuzzyMapTest {
  static MatchStyle FILE = MatchStyle.FILENAME;
  static MatchStyle SYM = MatchStyle.SYMBOL;

  void map({
    required String str,
    required String want,
    MatchStyle matchStyle = MatchStyle.TEXT,
  }) {
    //    test('maps characters of $str', () {
    var out = List<CharRole>.filled(str.length, CharRole.NONE);
    var matcher = FuzzyMatcher('', matchStyle: matchStyle);
    matcher.fuzzyMap(str, out);
    var result = '';
    var map = ' /cuC';
    for (var i = 0; i < str.length; i++) {
      result += map[out[i].index];
    }
    expect(result, want);
    //    });
  }

  void test_map() {
    // Text
    map(str: 'abc', want: 'Ccc');
    map(str: '.abc', want: ' Ccc');
    map(str: 'abc def', want: 'Ccc Ccc');
    map(str: 'SWT MyID', want: 'Cuu CcCu');
    map(str: 'ID', want: 'Cu');
    map(str: ' ID ', want: ' Cu ');
    map(str: 'IDSome', want: 'CuCccc');
    map(str: '0123456789', want: 'Cccccccccc');
    map(str: 'abcdefghigklmnopqrstuvwxyz', want: 'Cccccccccccccccccccccccccc');
    map(str: 'ABCDEFGHIGKLMNOPQRSTUVWXYZ', want: 'Cuuuuuuuuuuuuuuuuuuuuuuuuu');
    map(str: 'こんにちは', want: 'Ccccc');
    map(str: ':/.', want: '   ');

    // File names
    map(str: 'abc/def', want: 'Ccc/Ccc', matchStyle: FILE);
    map(str: ' abc_def', want: ' Ccc Ccc', matchStyle: FILE);
    map(str: ' abc_DDf', want: ' Ccc CCc', matchStyle: FILE);
    map(str: ':.', want: '  ', matchStyle: FILE);

    // Symbols
    map(str: 'abc::def::goo', want: 'Ccc//Ccc//Ccc', matchStyle: SYM);
    map(str: 'proto::Message', want: 'Ccccc//Ccccccc', matchStyle: SYM);
    map(str: 'AbstractSWTFactory', want: 'CcccccccCuuCcccccc', matchStyle: SYM);
    map(str: 'Abs012', want: 'Cccccc', matchStyle: SYM);
    map(str: 'public setFoo', want: 'Cccccc/CccCcc', matchStyle: SYM);
    map(str: '/', want: ' ', matchStyle: SYM);
  }
}

@reflectiveTest
class FuzzyMatcherTest {
  void expectMatch(FuzzyMatcher matcher, String str, String expected) {
    expect(matcher.score(str), greaterThan(0));
    expect(highlightMatches(str, matcher), expected);
  }

  void test_considersTheEmptyStringToMatchAll() {
    var matcher = FuzzyMatcher('');
    expect(matcher.score('def'), greaterThan(0));
    expect(matcher.getMatchedRanges(), isEmpty);
    expect(matcher.score('Ab stuff c'), greaterThan(0));
    expect(matcher.getMatchedRanges(), isEmpty);
  }

  void test_ranksActions() {
    var matcher = FuzzyMatcher('jade');
    // Full word matches score higher than subsequence matches.
    expect(
      matcher.score('jump to a directory in tree'),
      lessThan(
        matcher.score('fix imports and dependencies using jade (java only)'),
      ),
    );

    matcher = FuzzyMatcher('unedit');
    expect(
      matcher.score('Undo an edit'),
      lessThan(matcher.score('Close unedited tabs')),
    );

    matcher = FuzzyMatcher('fix');
    expect(
      matcher.score('find next match'),
      lessThan(
        matcher.score('format edited lines in workspace files (g4 fix)'),
      ),
    );
  }

  void test_ranksFileNames() {
    var matcher = FuzzyMatcher('aa', matchStyle: MatchStyle.FILENAME);
    // Full word matches scores higher than subsequence matches.
    expect(matcher.score('a/a/a'), lessThan(matcher.score('b/aa')));
    // Matches starting at the word boundary score higher.
    expect(matcher.score('baab'), lessThan(matcher.score('aabb')));
    // First word scores higher than later ones.
    expect(matcher.score('bb_aa'), lessThan(matcher.score('aa_bb')));
    // Tails don't matter.
    expect(matcher.score('aa_b'), matcher.score('aab'));
  }

  void test_ranksSymbols() {
    var matcher = FuzzyMatcher('Foo', matchStyle: MatchStyle.SYMBOL);
    // Prefix and complete matches are the same.
    expect(matcher.score('FooA'), matcher.score('Foo'));
    // First word scores higher than later ones.
    expect(matcher.score('BarFoo'), lessThan(matcher.score('FooBar')));
    // Aligned matches score higher.
    expect(matcher.score('Barfoo'), lessThan(matcher.score('BarFoo')));
    expect(matcher.score('F__oo'), matcher.score('F_oo'));
    expect(matcher.score('F_o_o'), lessThan(matcher.score('F_oo')));
    // Missed word vs a match in the middle.
    expect(matcher.score('BarFaoo'), lessThan(matcher.score('BarFaoFooa')));

    matcher = FuzzyMatcher('FooBar', matchStyle: MatchStyle.SYMBOL);
    // Ignores incomplete matches
    expect(matcher.score('FooaBar'), lessThan(matcher.score('FooBar')));
    expect(matcher.score('FooBara'), matcher.score('FooBar'));
    // Less words in the middle is better.
    expect(matcher.score('FooAtBaBar'), lessThan(matcher.score('FooAtBar')));
    expectMatch(matcher, 'FooAtBaBar', '[Foo]AtBa[Bar]');
  }

  void test_respectsTheBasename() {
    var matcher = FuzzyMatcher('subs', matchStyle: MatchStyle.FILENAME);
    expect(matcher.score('sub/seq'), greaterThanOrEqualTo(0));
    expect(matcher.score('sub/seq/end'), -1);
    expect(matcher.score('sub/seq'), greaterThanOrEqualTo(0));
    expect(matcher.score('sub/seq/base'), greaterThanOrEqualTo(0));
  }

  void test_worksWithDepotPaths() {
    var matcher = FuzzyMatcher('subs', matchStyle: MatchStyle.FILENAME);
    expect(matcher.score('//sub/seq'), greaterThanOrEqualTo(0));
    expect(matcher.score('//sub/seq/end'), -1);
    expect(matcher.score('//sub/seq'), greaterThanOrEqualTo(0));
    expect(matcher.score('//sub/seq/base'), greaterThanOrEqualTo(0));
  }

  void test_worksWithSimpleCases() {
    var matcher = FuzzyMatcher('abc');
    expect(matcher.score('def'), -1);
    expect(matcher.score('abd'), -1);
    expect(matcher.score('abc'), greaterThan(0));
    expect(matcher.score('Abc'), greaterThan(0));
    expect(matcher.score('Ab stuff c'), greaterThan(0));
  }

  void test_worksWithUpperCasePatterns() {
    var matcher = FuzzyMatcher('Abc');
    expect(matcher.score('def'), -1);
    expect(matcher.score('abd'), -1);
    expect(matcher.score('abc'), greaterThan(0));
    expect(matcher.score('Abc'), greaterThan(0));
    expect(matcher.score('Ab stuff c'), greaterThan(0));
  }
}

@reflectiveTest
class FuzzyScorerTest {
  static MatchStyle FILE = MatchStyle.FILENAME;

  static MatchStyle SYM = MatchStyle.SYMBOL;

  void score({
    required String p,
    required String str,
    String? want,
    MatchStyle input = MatchStyle.TEXT,
  }) {
    //    test('scores $str against $p', () {
    var matcher = FuzzyMatcher(p, matchStyle: input);
    if (want != null) {
      expect(matcher.score(str), greaterThanOrEqualTo(0));
      expect(highlightMatches(str, matcher), want);
    } else {
      expect(matcher.score(str), -1);
    }
    //    });
  }

  void test_scorer() {
    // Text
    score(p: 'a', str: 'abc', want: '[a]bc');
    score(p: 'aaa', str: 'aaa', want: '[aaa]');
    score(p: 'aaa', str: 'abab');
    score(p: 'aaba', str: 'abababa', want: '[a]b[aba]ba');
    score(p: 'cabaa', str: 'c_babababa', want: '[c]_b[aba]b[a]ba');
    score(p: 'caaa', str: 'c_babababaaa', want: '[c]_bababab[aaa]');
    score(p: 'aaa', str: 'aaababababaaa', want: '[aaa]babababaaa');
    score(
      p: 'unedit',
      str: 'Close unedited tabs',
      want: 'Close [unedit]ed tabs',
    );
    // Forward slashes are ignored in the non-filename mode.
    score(p: 'aaa', str: 'aaabab/ababaaa', want: '[aaa]bab/ababaaa');
    score(p: 'aaa', str: 'baaabab/abab_aaa', want: 'baaabab/abab_[aaa]');

    // Filenames.
    score(p: 'aa', str: 'a_a/a_a', want: '[a]_a/[a]_a', input: FILE);
    score(p: 'aaaa', str: 'a_a/a_a', want: '[a]_[a]/[a]_[a]', input: FILE);
    score(p: 'aaaa', str: 'aaaa', want: '[aaaa]', input: FILE);
    score(p: 'aaaa', str: 'a_a/a_aaaa', want: 'a_a/[a]_[aaa]a', input: FILE);
    score(p: 'aaaa', str: 'a_a/aaaaa', want: 'a_a/[aaaa]a', input: FILE);
    score(p: 'aaaa', str: 'aabaaa', want: '[aa]b[aa]a', input: FILE);
    score(p: 'aaaa', str: 'a/baaa', want: '[a]/b[aaa]', input: FILE);
    score(p: 'aaaa', str: 'a/baaa/', want: '[a]/b[aaa]/', input: FILE);
    score(
      p: 'abcxz',
      str: 'd/abc/abcd/oxz',
      want: 'd/[abc]/abcd/o[xz]',
      input: FILE,
    );
    score(
      p: 'abcxz',
      str: 'd/abcd/abc/oxz',
      want: 'd/[abc]d/abc/o[xz]',
      input: FILE,
    );

    // Symbols
    score(p: 'foo', str: 'abc::foo', want: 'abc::[foo]', input: SYM);
    score(p: 'foo', str: 'abc::foo::', want: 'abc::[foo]::', input: SYM);
    score(p: 'foo', str: 'foo.foo', want: 'foo.[foo]', input: SYM);
    score(p: 'foo', str: 'fo_oo.oo_oo', want: '[f]o_oo.[oo]_oo', input: SYM);
    score(p: 'foo', str: 'fo_oo.fo_oo', want: 'fo_oo.[fo]_[o]o', input: SYM);
    score(p: 'fo_o', str: 'fo_oo.o_oo', want: '[f]o_oo.[o_o]o', input: SYM);
    score(p: 'fOO', str: 'fo_oo.o_oo', want: '[f]o_oo.[o]_[o]o', input: SYM);
    score(
      p: 'tedit',
      str: 'foo.TextEdit',
      want: 'foo.[T]ext[Edit]',
      input: SYM,
    );
    score(
      p: 'tedit',
      str: '*foo.TextEdit',
      want: '*foo.[T]ext[Edit]',
      input: SYM,
    );
    score(
      p: 'TEdit',
      str: 'foo.TextEdit',
      want: 'foo.[T]ext[Edit]',
      input: SYM,
    );
    score(
      p: 'Tedit',
      str: 'foo.TextEdit',
      want: 'foo.[T]ext[Edit]',
      input: SYM,
    );
    score(
      p: 'Tedit',
      str: 'foo.Textedit',
      want: 'foo.[Te]xte[dit]',
      input: SYM,
    );
    score(p: 'TEdit', str: 'foo.Textedit', input: SYM);
    score(p: 'te', str: 'foo.Textedit', want: 'foo.[Te]xtedit', input: SYM);
    score(p: 'ee', str: 'foo.Textedit', input: SYM);
    score(p: 'ex', str: 'foo.Textedit', want: 'foo.T[ex]tedit', input: SYM);
    score(p: 'exdi', str: 'foo.Textedit', input: SYM);
    score(p: 'exdit', str: 'foo.Textedit', input: SYM);
    score(
      p: 'extdit',
      str: 'foo.Textedit',
      want: 'foo.T[ext]e[dit]',
      input: SYM,
    );
    score(p: 'e', str: 'foo.Textedit', want: 'foo.T[e]xtedit', input: SYM);
    score(p: 'ed', str: 'foo.Textedit', want: 'foo.Text[ed]it', input: SYM);
    score(p: 'edt', str: 'foo.Textedit', input: SYM);
    score(p: 'edit', str: 'foo.Textedit', want: 'foo.Text[edit]', input: SYM);
    score(
      p: 'pub',
      str: 'public setPubl',
      want: 'public set[Pub]l',
      input: SYM,
    );
    score(
      p: 'mod',
      str: 'public List<AbstractModule> getMods',
      want: 'public List<AbstractModule> get[Mod]s',
      input: SYM,
    );
    score(
      p: 'm',
      str: 'public List<AbstractModule> getMods',
      want: 'public List<AbstractModule> get[M]ods',
      input: SYM,
    );
    score(p: 'f', str: '[]foo.Foo', want: '[]foo.[F]oo', input: SYM);
    score(
      p: 'edin',
      str: 'foo.TexteditNum',
      want: 'foo.Text[edi]t[N]um',
      input: SYM,
    );
  }
}

@reflectiveTest
class ScoringFunctionTest {
  ///
  void score({required String p, required String str, required double want}) {
    //    test('scores $str against $p', () {
    var matcher = FuzzyMatcher(p, matchStyle: MatchStyle.SYMBOL);
    expect(
      matcher.score(str).toStringAsFixed(4).startsWith(want.toStringAsFixed(4)),
      true,
    );
    //    });
  }

  void test_score() {
    // This is a regression test. Feel free to update numbers below if the new
    // ones are reasonable. Use 5 digits after the period.
    score(p: 'abc', str: 'abc', want: 1); // perfect
    score(p: 'abc', str: 'Abc', want: 1); // almost perfect
    score(p: 'abc', str: 'Abcdef', want: 1);
    score(p: 'strc', str: 'StrCat', want: 1);
    score(p: 'abc_def', str: 'abc_def_xyz', want: 1);
    score(p: 'abcdef', str: 'abc_def_xyz', want: 0.91667);
    score(p: 'abcxyz', str: 'abc_def_xyz', want: 0.875);
    score(p: 'sc', str: 'StrCat', want: 0.75);
    score(p: 'abc', str: 'AbstrBasicCtor', want: 0.75);
    // Qualified symbols.
    score(p: 'foo', str: 'abc::foo', want: 1);
    score(p: 'afoo', str: 'abc::foo', want: 0.9375);
    score(p: 'abr', str: 'abc::bar', want: 0.5);
    score(p: 'br', str: 'abc::bar', want: 0.375);
    score(p: 'aar', str: 'abc::bar', want: 0.16667);
    score(p: 'edin', str: 'foo.TexteditNum', want: 0.0625); // poor match
    score(p: 'ediu', str: 'foo.TexteditNum', want: 0); // poor match
    // We want the next two items to have roughly similar scores.
    score(p: 'up', str: 'unique_ptr', want: 0.75);
    score(p: 'up', str: 'upper_bound', want: 1);
  }
}
