// Copyright 2019 Google Inc. Use of this source code is governed by an
// MIT-style license that can be found in the LICENSE file or at
// https://opensource.org/licenses/MIT.

@TestOn('vm')
library;

import 'package:path/path.dart' as p;
import 'package:pub_semver/pub_semver.dart';
import 'package:source_maps/source_maps.dart' as source_maps;
import 'package:test/test.dart';
import 'package:test_descriptor/test_descriptor.dart' as d;

import 'package:sass/src/embedded/embedded_sass.pb.dart';
import 'package:sass/src/embedded/utils.dart';

import 'embedded_process.dart';
import 'utils.dart';

void main() {
  late EmbeddedProcess process;
  setUp(() async {
    process = await EmbeddedProcess.start();
  });

  group("exits upon protocol error", () {
    test("caused by an empty message", () async {
      process.send(InboundMessage());
      await expectParseError(process, "InboundMessage.message is not set.");
      await process.shouldExit(76);
    });

    test("caused by an unterminated compilation ID varint", () async {
      process.stdin.add([1, 0x81]);
      await expectParseError(
        process,
        "Invalid compilation ID: continuation bit always set.",
        compilationId: errorId,
      );
      await process.shouldExit(76);
    });

    test("caused by a 33-bit compilation ID varint", () async {
      var varint = serializeVarint(0x100000000);
      process.stdin.add([...serializeVarint(varint.length), ...varint]);
      await expectParseError(
        process,
        "Varint compilation ID was longer than 32 bits.",
        compilationId: errorId,
      );
      await process.shouldExit(76);
    });

    test("caused by an invalid protobuf", () async {
      process.stdin.add([2, 1, 0]);
      await expectParseError(
        process,
        "Protocol message contained an invalid tag (zero).",
        compilationId: 1,
      );
      await process.shouldExit(76);
    });

    test("caused by a response to an inactive compilation", () async {
      process.send(
        InboundMessage()
          ..canonicalizeResponse =
              (InboundMessage_CanonicalizeResponse()..id = 1),
      );
      await expectParamsError(
        process,
        errorId,
        "Response ID 1 doesn't match any outstanding requests in "
        "compilation $defaultCompilationId.",
      );
      await process.shouldExit(76);
    });

    test("caused by duplicate compilation IDs", () async {
      process.send(
        compileString(
          "@use 'other'",
          importers: [InboundMessage_CompileRequest_Importer()..importerId = 1],
        ),
      );
      await getCanonicalizeRequest(process);

      process.send(compileString("a {b: c}"));
      await expectParamsError(
        process,
        errorId,
        "A CompileRequest with compilation ID $defaultCompilationId is "
        "already active.",
      );
      await process.shouldExit(76);
    });
  });

  test("a version response is valid", () async {
    process.inbound.add((
      0,
      InboundMessage()
        ..versionRequest = (InboundMessage_VersionRequest()..id = 123),
    ));
    var (compilationId, OutboundMessage(versionResponse: response)) =
        await process.outbound.next;
    expect(compilationId, equals(0));
    expect(response.id, equals(123));

    Version.parse(response.protocolVersion); // shouldn't throw
    Version.parse(response.compilerVersion); // shouldn't throw
    Version.parse(response.implementationVersion); // shouldn't throw
    expect(response.implementationName, equals("dart-sass"));
    await process.close();
  });

  group("compiles CSS from", () {
    test("an SCSS string by default", () async {
      process.send(compileString("a {b: 1px + 2px}"));
      await expectSuccess(process, "a { b: 3px; }");
      await process.close();
    });

    test("an SCSS string explicitly", () async {
      process.send(compileString("a {b: 1px + 2px}", syntax: Syntax.SCSS));
      await expectSuccess(process, "a { b: 3px; }");
      await process.close();
    });

    test("an indented syntax string", () async {
      process.send(compileString("a\n  b: 1px + 2px", syntax: Syntax.INDENTED));
      await expectSuccess(process, "a { b: 3px; }");
      await process.close();
    });

    test("a plain CSS string", () async {
      process.send(compileString("a {b: c}", syntax: Syntax.CSS));
      await expectSuccess(process, "a { b: c; }");
      await process.close();
    });

    test("an absolute path", () async {
      await d.file("test.scss", "a {b: 1px + 2px}").create();

      process.send(
        InboundMessage()
          ..compileRequest = (InboundMessage_CompileRequest()
            ..path = p.absolute(d.path("test.scss"))),
      );
      await expectSuccess(process, "a { b: 3px; }");
      await process.close();
    });

    test("a relative path", () async {
      await d.file("test.scss", "a {b: 1px + 2px}").create();

      process.send(
        InboundMessage()
          ..compileRequest = (InboundMessage_CompileRequest()
            ..path = p.relative(d.path("test.scss"))),
      );
      await expectSuccess(process, "a { b: 3px; }");
      await process.close();
    });
  });

  group("compiles CSS in", () {
    test("expanded mode", () async {
      process.send(
        compileString("a {b: 1px + 2px}", style: OutputStyle.EXPANDED),
      );
      await expectSuccess(process, equals("a {\n  b: 3px;\n}"));
      await process.close();
    });

    test("compressed mode", () async {
      process.send(
        compileString("a {b: 1px + 2px}", style: OutputStyle.COMPRESSED),
      );
      await expectSuccess(process, equals("a{b:3px}"));
      await process.close();
    });
  });

  group("exits when stdin is closed", () {
    test("immediately", () async {
      process.stdin.close();
      await process.shouldExit(0);
    });

    test("after compiling CSS", () async {
      process.send(compileString("a {b: 1px + 2px}"));
      await expectSuccess(process, equals("a {\n  b: 3px;\n}"));
      process.stdin.close();
      await process.shouldExit(0);
    });

    test("while compiling CSS", () async {
      process.send(compileString("a {b: foo() + 2px}", functions: [r"foo()"]));
      await getFunctionCallRequest(process);
      process.stdin.close();
      await process.shouldExit(0);
    });
  });

  test("handles many concurrent compilation requests", () async {
    var totalRequests = 1000;
    for (var i = 1; i <= totalRequests; i++) {
      process.inbound.add((
        i,
        compileString("a {b: foo() + 2px}", functions: [r"foo()"]),
      ));
    }

    var successes = 0;
    process.outbound.rest.listen((pair) {
      var (compilationId, message) = pair;
      expect(
        compilationId,
        allOf(greaterThan(0), lessThanOrEqualTo(totalRequests)),
      );

      if (message.hasFunctionCallRequest()) {
        process.inbound.add((
          compilationId,
          InboundMessage()
            ..functionCallResponse = (InboundMessage_FunctionCallResponse()
              ..id = message.functionCallRequest.id
              ..success = (Value()
                ..number = (Value_Number()
                  ..value = 1
                  ..numerators.add("px")))),
        ));
      } else if (message.hasCompileResponse()) {
        var response = message.compileResponse;
        expect(response.hasSuccess(), isTrue);
        expect(response.success.css, equalsIgnoringWhitespace("a { b: 3px; }"));

        successes++;
        if (successes == totalRequests) {
          process.stdin.close();
        }
      } else {
        fail("Unexpected message ${message.toDebugString()}");
      }
    });

    await process.shouldExit(0);
  });

  // Regression test for sass/dart-sass#2004
  test("handles many sequential compilation requests", () async {
    var totalRequests = 1000;
    for (var i = 1; i <= totalRequests; i++) {
      process.send(compileString("a {b: 1px + 2px}"));
      await expectSuccess(process, "a { b: 3px; }");
    }
    await process.close();
  });

  test("closes gracefully with many in-flight compilations", () async {
    // This should always be equal to the size of
    // [IsolateDispatcher._isolatePool], since that's as many concurrent
    // compilations as we can realistically have anyway.
    var totalRequests = 15;
    for (var i = 1; i <= totalRequests; i++) {
      process.inbound.add((
        i,
        compileString("a {b: foo() + 2px}", functions: [r"foo()"]),
      ));
    }

    await process.close();
  });

  test("doesn't include a source map by default", () async {
    process.send(compileString("a {b: 1px + 2px}"));
    await expectSuccess(process, "a { b: 3px; }", sourceMap: isEmpty);
    await process.close();
  });

  test("doesn't include a source map with source_map: false", () async {
    process.send(compileString("a {b: 1px + 2px}", sourceMap: false));
    await expectSuccess(process, "a { b: 3px; }", sourceMap: isEmpty);
    await process.close();
  });

  test("includes a source map if source_map is true", () async {
    process.send(compileString("a {b: 1px + 2px}", sourceMap: true));
    await expectSuccess(
      process,
      "a { b: 3px; }",
      sourceMap: (String map) {
        var mapping = source_maps.parse(map);
        var span = mapping.spanFor(2, 5)!;
        expect(span.start.line, equals(0));
        expect(span.start.column, equals(3));
        expect(span.end, equals(span.start));
        expect(mapping, isA<source_maps.SingleMapping>());
        expect((mapping as source_maps.SingleMapping).files[0], isNull);
        return true;
      },
    );
    await process.close();
  });

  test(
    "includes a source map without content if source_map is true and source_map_include_sources is false",
    () async {
      process.send(
        compileString(
          "a {b: 1px + 2px}",
          sourceMap: true,
          sourceMapIncludeSources: false,
        ),
      );
      await expectSuccess(
        process,
        "a { b: 3px; }",
        sourceMap: (String map) {
          var mapping = source_maps.parse(map);
          var span = mapping.spanFor(2, 5)!;
          expect(span.start.line, equals(0));
          expect(span.start.column, equals(3));
          expect(span.end, equals(span.start));
          expect(mapping, isA<source_maps.SingleMapping>());
          expect((mapping as source_maps.SingleMapping).files[0], isNull);
          return true;
        },
      );
      await process.close();
    },
  );

  test(
    "includes a source map with content if source_map is true and source_map_include_sources is true",
    () async {
      process.send(
        compileString(
          "a {b: 1px + 2px}",
          sourceMap: true,
          sourceMapIncludeSources: true,
        ),
      );
      await expectSuccess(
        process,
        "a { b: 3px; }",
        sourceMap: (String map) {
          var mapping = source_maps.parse(map);
          var span = mapping.spanFor(2, 5)!;
          expect(span.start.line, equals(0));
          expect(span.start.column, equals(3));
          expect(span.end, equals(span.start));
          expect(mapping, isA<source_maps.SingleMapping>());
          expect((mapping as source_maps.SingleMapping).files[0], isNotNull);
          return true;
        },
      );
      await process.close();
    },
  );

  group("emits a log event", () {
    group("for a @debug rule", () {
      test("with correct fields", () async {
        process.send(compileString("a {@debug hello}"));

        var logEvent = await getLogEvent(process);
        expect(logEvent.type, equals(LogEventType.DEBUG));
        expect(logEvent.message, equals("hello"));
        expect(logEvent.span.text, equals("@debug hello"));
        expect(logEvent.span.start, equals(location(3, 0, 3)));
        expect(logEvent.span.end, equals(location(15, 0, 15)));
        expect(logEvent.span.context, equals("a {@debug hello}"));
        expect(logEvent.stackTrace, isEmpty);
        expect(logEvent.formatted, equals('-:1 DEBUG: hello\n'));
        await process.kill();
      });

      test("formatted with terminal colors", () async {
        process.send(compileString("a {@debug hello}", alertColor: true));
        var logEvent = await getLogEvent(process);
        expect(
          logEvent.formatted,
          equals('-:1 \u001b[1mDebug\u001b[0m: hello\n'),
        );
        await process.kill();
      });
    });

    group("for a @warn rule", () {
      test("with correct fields", () async {
        process.send(compileString("a {@warn hello}"));

        var logEvent = await getLogEvent(process);
        expect(logEvent.type, equals(LogEventType.WARNING));
        expect(logEvent.message, equals("hello"));
        expect(logEvent.span, equals(SourceSpan()));
        expect(logEvent.stackTrace, equals("- 1:4  root stylesheet\n"));
        expect(
          logEvent.formatted,
          equals(
            'WARNING: hello\n'
            '    - 1:4  root stylesheet\n',
          ),
        );
        await process.kill();
      });

      test("formatted with terminal colors", () async {
        process.send(compileString("a {@warn hello}", alertColor: true));
        var logEvent = await getLogEvent(process);
        expect(
          logEvent.formatted,
          equals(
            '\x1B[33m\x1B[1mWarning\x1B[0m: hello\n'
            '    - 1:4  root stylesheet\n',
          ),
        );
        await process.kill();
      });

      test("encoded in ASCII", () async {
        process.send(compileString("a {@debug a && b}", alertAscii: true));
        var logEvent = await getLogEvent(process);
        expect(
          logEvent.formatted,
          equals(
            'WARNING: In Sass, "&&" means two copies of the parent '
            'selector. You probably want to use "and" instead.\n\n'
            '  ,\n'
            '1 | a {@debug a && b}\n'
            '  |             ^^\n'
            '  \'\n'
            '    - 1:13  root stylesheet\n',
          ),
        );
        await process.kill();
      });
    });

    test("for a parse-time deprecation warning", () async {
      process.send(compileString("@if true {} @elseif true {}"));

      var logEvent = await getLogEvent(process);
      expect(logEvent.type, equals(LogEventType.DEPRECATION_WARNING));
      expect(
        logEvent.message,
        equals(
          '@elseif is deprecated and will not be supported in future Sass '
          'versions.\n'
          '\n'
          'Recommendation: @else if',
        ),
      );
      expect(logEvent.span.text, equals("@elseif"));
      expect(logEvent.span.start, equals(location(12, 0, 12)));
      expect(logEvent.span.end, equals(location(19, 0, 19)));
      expect(logEvent.span.context, equals("@if true {} @elseif true {}"));
      expect(logEvent.stackTrace, "- 1:13  root stylesheet\n");
      await process.kill();
    });

    test("for a runtime deprecation warning", () async {
      process.send(compileString("a {\$var: value !global}"));

      var logEvent = await getLogEvent(process);
      expect(logEvent.type, equals(LogEventType.DEPRECATION_WARNING));
      expect(
        logEvent.message,
        equals(
          "As of Dart Sass 2.0.0, !global assignments won't be able to "
          "declare new variables.\n"
          "\n"
          "Recommendation: add `\$var: null` at the stylesheet root.",
        ),
      );
      expect(logEvent.span.text, equals("\$var: value !global"));
      expect(logEvent.span.start, equals(location(3, 0, 3)));
      expect(logEvent.span.end, equals(location(22, 0, 22)));
      expect(logEvent.span.context, equals("a {\$var: value !global}"));
      expect(logEvent.stackTrace, "- 1:4  root stylesheet\n");
      await process.kill();
    });
  });

  group("gracefully handles an error", () {
    test("from invalid syntax", () async {
      process.send(compileString("a {b: }"));

      var failure = await getCompileFailure(process);
      expect(failure.message, equals("Expected expression."));
      expect(failure.span.text, isEmpty);
      expect(failure.span.start, equals(location(6, 0, 6)));
      expect(failure.span.end, equals(location(6, 0, 6)));
      expect(failure.span.url, isEmpty);
      expect(failure.span.context, equals("a {b: }"));
      expect(failure.stackTrace, equals("- 1:7  root stylesheet\n"));
      await process.close();
    });

    test("from the runtime", () async {
      process.send(compileString("a {b: 1px + 1em}"));

      var failure = await getCompileFailure(process);
      expect(failure.message, equals("1px and 1em have incompatible units."));
      expect(failure.span.text, "1px + 1em");
      expect(failure.span.start, equals(location(6, 0, 6)));
      expect(failure.span.end, equals(location(15, 0, 15)));
      expect(failure.span.url, isEmpty);
      expect(failure.span.context, equals("a {b: 1px + 1em}"));
      expect(failure.stackTrace, equals("- 1:7  root stylesheet\n"));
      await process.close();
    });

    test("from a missing file", () async {
      process.send(
        InboundMessage()
          ..compileRequest =
              (InboundMessage_CompileRequest()..path = d.path("test.scss")),
      );

      var failure = await getCompileFailure(process);
      expect(failure.message, startsWith("Cannot open file: "));
      expect(
        failure.message.replaceFirst("Cannot open file: ", "").trim(),
        equalsPath(d.path('test.scss')),
      );
      expect(failure.span.text, equals(''));
      expect(failure.span.context, equals(''));
      expect(failure.span.start, equals(SourceSpan_SourceLocation()));
      expect(failure.span.end, equals(SourceSpan_SourceLocation()));
      expect(failure.span.url, equals(p.toUri(d.path('test.scss')).toString()));
      expect(failure.stackTrace, isEmpty);
      await process.close();
    });

    test("with a multi-line source span", () async {
      process.send(
        compileString("""
          a {
            b: 1px +
                1em;
          }
        """),
      );

      var failure = await getCompileFailure(process);
      expect(failure.span.text, "1px +\n                1em");
      expect(failure.span.start, equals(location(29, 1, 15)));
      expect(failure.span.end, equals(location(54, 2, 19)));
      expect(failure.span.url, isEmpty);
      expect(failure.span.context,
          equals("            b: 1px +\n                1em;\n"));
      expect(failure.stackTrace, equals("- 2:16  root stylesheet\n"));
      await process.close();
    });

    test("with multiple stack trace entries", () async {
      process.send(
        compileString("""
          @function fail() {
            @return 1px + 1em;
          }

          a {
            b: fail();
          }
        """),
      );

      var failure = await getCompileFailure(process);
      expect(
        failure.stackTrace,
        equals(
          "- 2:21  fail()\n"
          "- 6:16  root stylesheet\n",
        ),
      );
      await process.close();
    });

    group("and includes the URL from", () {
      test("a string input", () async {
        process.send(compileString("a {b: 1px + 1em}", url: "foo://bar/baz"));

        var failure = await getCompileFailure(process);
        expect(failure.span.url, equals("foo://bar/baz"));
        expect(
          failure.stackTrace,
          equals("foo://bar/baz 1:7  root stylesheet\n"),
        );
        await process.close();
      });

      test("a path input", () async {
        await d.file("test.scss", "a {b: 1px + 1em}").create();
        var path = d.path("test.scss");
        process.send(
          InboundMessage()
            ..compileRequest = (InboundMessage_CompileRequest()..path = path),
        );

        var failure = await getCompileFailure(process);
        expect(p.fromUri(failure.span.url), equalsPath(path));
        expect(failure.stackTrace, endsWith(" 1:7  root stylesheet\n"));
        expect(failure.stackTrace.split(" ").first, equalsPath(path));
        await process.close();
      });
    });

    test("caused by using Sass features in CSS", () async {
      process.send(compileString("a {b: 1px + 2px}", syntax: Syntax.CSS));

      var failure = await getCompileFailure(process);
      expect(failure.message, equals("Operators aren't allowed in plain CSS."));
      expect(failure.span.text, "+");
      expect(failure.span.start, equals(location(10, 0, 10)));
      expect(failure.span.end, equals(location(11, 0, 11)));
      expect(failure.span.url, isEmpty);
      expect(failure.span.context, equals("a {b: 1px + 2px}"));
      expect(failure.stackTrace, equals("- 1:11  root stylesheet\n"));
      await process.close();
    });

    group("and provides a formatted", () {
      test("message", () async {
        process.send(compileString("a {b: 1px + 1em}"));

        var failure = await getCompileFailure(process);
        expect(
          failure.formatted,
          equals(
            'Error: 1px and 1em have incompatible units.\n'
            '  ╷\n'
            '1 │ a {b: 1px + 1em}\n'
            '  │       ^^^^^^^^^\n'
            '  ╵\n'
            '  - 1:7  root stylesheet',
          ),
        );
        await process.close();
      });

      test("message with terminal colors", () async {
        process.send(compileString("a {b: 1px + 1em}", alertColor: true));

        var failure = await getCompileFailure(process);
        expect(
          failure.formatted,
          equals(
            'Error: 1px and 1em have incompatible units.\n'
            '\x1B[34m  ╷\x1B[0m\n'
            '\x1B[34m1 │\x1B[0m a {b: \x1B[31m1px + 1em\x1B[0m}\n'
            '\x1B[34m  │\x1B[0m \x1B[31m      ^^^^^^^^^\x1B[0m\n'
            '\x1B[34m  ╵\x1B[0m\n'
            '  - 1:7  root stylesheet',
          ),
        );
        await process.close();
      });

      test("message with ASCII encoding", () async {
        process.send(compileString("a {b: 1px + 1em}", alertAscii: true));

        var failure = await getCompileFailure(process);
        expect(
          failure.formatted,
          equals(
            'Error: 1px and 1em have incompatible units.\n'
            '  ,\n'
            '1 | a {b: 1px + 1em}\n'
            '  |       ^^^^^^^^^\n'
            '  \'\n'
            '  - 1:7  root stylesheet',
          ),
        );
        await process.close();
      });
    });
  });
}
