// Copyright (c) 2019, 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 'dart:mirrors';

import 'package:html/dom.dart' show Element;
import 'package:html/parser.dart' show parseFragment;
import 'package:markdown/markdown.dart'
    show
        AutolinkExtensionSyntax,
        BlockSyntax,
        InlineSyntax,
        StrikethroughSyntax,
        TableSyntax,
        markdownToHtml;
import 'package:path/path.dart' as p;

import '../test/util.dart';

// Locate the "tool" directory. Use mirrors so that this works with the test
// package, which loads this suite into an isolate.
String get toolDir {
  final path = (reflect(loadCommonMarkSections) as ClosureMirror)
      .function
      .location!
      .sourceUri
      .path;

  return p.dirname(path);
}

File getStatsFile(String prefix) =>
    File(p.join(toolDir, '${prefix}_stats.json'));

Map<String, List<CommonMarkTestCase>> loadCommonMarkSections(
  String testPrefix,
) {
  final testFile = File(p.join(toolDir, '${testPrefix}_tests.json'));
  final testsJson = testFile.readAsStringSync();

  final testArray = jsonDecode(testsJson) as List;

  final sections = <String, List<CommonMarkTestCase>>{};

  for (final exampleMap in testArray) {
    final exampleTest = CommonMarkTestCase.fromJson(
      exampleMap as Map<String, dynamic>,
    );

    final sectionList = sections.putIfAbsent(
      exampleTest.section,
      () => <CommonMarkTestCase>[],
    );

    sectionList.add(exampleTest);
  }

  return sections;
}

class Config {
  static final Config commonMarkConfig = Config._(
    'common_mark',
    'https://spec.commonmark.org/0.30/',
  );
  static final Config gfmConfig = Config._(
    'gfm',
    'https://github.github.com/gfm/',
  );

  final String prefix;
  final String baseUrl;

  Config._(this.prefix, this.baseUrl);
}

class CommonMarkTestCase {
  final String markdown;
  final String section;
  final int example;
  final String html;
  final int startLine;
  final int endLine;
  final Set<String> extensions;

  CommonMarkTestCase(
    this.example,
    this.section,
    this.startLine,
    this.endLine,
    this.markdown,
    this.html,
    this.extensions,
  );

  factory CommonMarkTestCase.fromJson(Map<String, dynamic> json) {
    return CommonMarkTestCase(
      json['example'] as int,
      json['section'] as String /*!*/,
      json['start_line'] as int,
      json['end_line'] as int,
      json['markdown'] as String /*!*/,
      json['html'] as String,
      json['extensions'] == null
          ? const {}
          : Set.from(json['extensions'] as List),
    );
  }

  @override
  String toString() => '$section - $example';
}

enum CompareLevel { strict, loose, fail, error }

class CompareResult {
  final CompareLevel compareLevel;
  final CommonMarkTestCase testCase;
  final String? result;

  CompareResult(this.testCase, this.result, this.compareLevel);
}

CompareResult compareResult(
  Config config,
  CommonMarkTestCase testCase, {
  bool throwOnError = false,
  bool verboseFail = false,
  bool verboseLooseMatch = false,
  Set<String> extensions = const {},
}) {
  var enabletagfilter = false;

  String output;
  final inlineSyntaxes = <InlineSyntax>[];
  final blockSyntaxes = <BlockSyntax>[];

  for (final extension in extensions) {
    switch (extension) {
      case 'autolink':
        inlineSyntaxes.add(AutolinkExtensionSyntax());
        break;
      case 'strikethrough':
        inlineSyntaxes.add(StrikethroughSyntax());
        break;
      case 'table':
        blockSyntaxes.add(const TableSyntax());
        break;
      case 'tagfilter':
        enabletagfilter = true;
        break;
      default:
        throw UnimplementedError('Unimplemented extension "$extension"');
    }
  }

  try {
    output = markdownToHtml(
      testCase.markdown,
      inlineSyntaxes: inlineSyntaxes,
      blockSyntaxes: blockSyntaxes,
      enableTagfilter: enabletagfilter,
    );
  } catch (err, stackTrace) {
    if (throwOnError) {
      rethrow;
    }
    if (verboseFail) {
      _printVerboseFailure(
        config.baseUrl,
        'ERROR',
        testCase,
        'Thrown: $err\n$stackTrace',
      );
    }

    return CompareResult(testCase, null, CompareLevel.error);
  }

  if (testCase.html == output) {
    return CompareResult(testCase, output, CompareLevel.strict);
  }

  final expectedParsed = parseFragment(testCase.html);
  final actual = parseFragment(output);

  final looseMatch = _compareHtml(expectedParsed.children, actual.children);

  if (!looseMatch && verboseFail) {
    _printVerboseFailure(config.baseUrl, 'FAIL', testCase, output);
  }

  if (looseMatch && verboseLooseMatch) {
    _printVerboseFailure(config.baseUrl, 'LOOSE', testCase, output);
  }

  return CompareResult(
    testCase,
    output,
    looseMatch ? CompareLevel.loose : CompareLevel.fail,
  );
}

String _indent(String s) =>
    s.splitMapJoin('\n', onNonMatch: (n) => '    ${whitespaceColor(n)}');

void _printVerboseFailure(
  String baseUrl,
  String message,
  CommonMarkTestCase testCase,
  String actual,
) {
  print(
    '$message: $baseUrl#example-${testCase.example} '
    '@ ${testCase.section}',
  );
  print('input:');
  print(_indent(testCase.markdown));
  print('expected:');
  print(_indent(testCase.html));
  print('actual:');
  print(_indent(actual));
  print('-----------------------');
}

/// Compare two DOM trees for equality.
bool _compareHtml(
  List<Element> expectedElements,
  List<Element> actualElements,
) {
  if (expectedElements.length != actualElements.length) {
    return false;
  }

  for (var childNum = 0; childNum < expectedElements.length; childNum++) {
    final expected = expectedElements[childNum];
    final actual = actualElements[childNum];

    if (expected.runtimeType != actual.runtimeType) {
      return false;
    }

    if (expected.localName != actual.localName) {
      return false;
    }

    if (expected.attributes.length != actual.attributes.length) {
      return false;
    }

    final expectedAttrKeys = expected.attributes.keys.toList();
    expectedAttrKeys.sort();

    final actualAttrKeys = actual.attributes.keys.toList();
    actualAttrKeys.sort();

    for (var attrNum = 0; attrNum < actualAttrKeys.length; attrNum++) {
      final expectedAttrKey = expectedAttrKeys[attrNum];
      final actualAttrKey = actualAttrKeys[attrNum];

      if (expectedAttrKey != actualAttrKey) {
        return false;
      }

      if (expected.attributes[expectedAttrKey] !=
          actual.attributes[actualAttrKey]) {
        return false;
      }
    }

    final childrenEqual = _compareHtml(expected.children, actual.children);

    if (!childrenEqual) {
      return false;
    }
  }

  return true;
}
