// Copyright (c) 2021, 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:async';
import 'dart:convert';

import 'package:dap/dap.dart';

class InvalidEncodingException extends InvalidHeadersException {
  InvalidEncodingException(String headers)
      : super('Encoding in supplied headers is not supported.', headers);
}

class InvalidHeadersException extends DebugAdapterException {
  InvalidHeadersException(String message, String headers)
      : super('$message\n\nHeaders:\n$headers');
}

/// Transforms a stream of LSP/DAP data in the form:
///
///     Content-Length: xxx\r\n
///     Content-Type: application/vscode-jsonrpc; charset=utf-8\r\n
///     \r\n
///     { JSON payload }
///
/// into just the JSON payload, decoded with the specified encoding. Line endings
/// for headers must be \r\n on all platforms as defined in the LSP spec.
class PacketTransformer extends StreamTransformerBase<List<int>, String> {
  @override
  Stream<String> bind(Stream<List<int>> stream) {
    late StreamSubscription<int> input;
    late StreamController<String> output;
    final buffer = <int>[];
    var isParsingHeaders = true;
    ProtocolHeaders? headers;
    output = StreamController<String>(
      onListen: () {
        input = stream.expand((b) => b).listen(
          (codeUnit) {
            buffer.add(codeUnit);
            try {
              if (isParsingHeaders && _endsWithCrLfCrLf(buffer)) {
                headers = _parseHeaders(buffer);
                buffer.clear();
                isParsingHeaders = false;
              } else if (!isParsingHeaders &&
                  buffer.length >= headers!.contentLength) {
                // UTF-8 is the default - and only supported - encoding for LSP.
                // The string 'utf8' is valid since it was published in the original spec.
                // Any other encodings should be rejected with an error.
                if ([null, 'utf-8', 'utf8']
                    .contains(headers?.encoding?.toLowerCase())) {
                  output.add(utf8.decode(buffer));
                } else {
                  output.addError(
                    InvalidEncodingException(headers!.rawHeaders),
                  );
                }
                buffer.clear();
                isParsingHeaders = true;
              }
            } on DebugAdapterException catch (e) {
              output.addError(e);
              output.close();
            }
          },
          onError: output.addError,
          onDone: output.close,
        );
      },
      onPause: () => input.pause(),
      onResume: () => input.resume(),
      onCancel: () => input.cancel(),
    );
    return output.stream;
  }

  /// Whether [buffer] ends in '\r\n\r\n'.
  static bool _endsWithCrLfCrLf(List<int> buffer) {
    var l = buffer.length;
    return l > 4 &&
        buffer[l - 1] == 10 &&
        buffer[l - 2] == 13 &&
        buffer[l - 3] == 10 &&
        buffer[l - 4] == 13;
  }

  static String? _extractEncoding(String header) {
    final charset = header
        .split(';')
        .map((s) => s.trim().toLowerCase())
        .firstWhere((s) => s.startsWith('charset='), orElse: () => '');

    return charset == '' ? null : charset.split('=')[1];
  }

  /// Decodes [buffer] into a String and returns the 'Content-Length' header value.
  static ProtocolHeaders _parseHeaders(List<int> buffer) {
    final String asString;
    try {
      // Headers are specified as always ASCII in LSP.
      asString = ascii.decode(buffer);
    } on FormatException {
      throw FormatException('Unable to decode headers with ascii. '
          'The stream has utf8 content:\n${utf8.decode(buffer)}');
    }
    final headers = asString.split('\r\n');
    final lengthHeader = headers.firstWhere(
        (h) => h.startsWith('Content-Length'),
        orElse: () => throw InvalidHeadersException(
            'No Content-Length header was supplied', asString));
    final length = lengthHeader.split(':').last.trim();
    final contentTypeHeader = headers
        .firstWhere((h) => h.startsWith('Content-Type'), orElse: () => '');
    final encoding = _extractEncoding(contentTypeHeader);
    return ProtocolHeaders(asString, int.parse(length), encoding);
  }
}

class ProtocolHeaders {
  final String rawHeaders;
  final int contentLength;
  final String? encoding;
  ProtocolHeaders(this.rawHeaders, this.contentLength, this.encoding);
}
