// Copyright (c) 2015, 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.

library convert.percent.encoder;

import 'dart:convert';

import 'package:charcode/ascii.dart';

/// The canonical instance of [PercentEncoder].
const percentEncoder = const PercentEncoder._();

/// A converter that encodes byte arrays into percent-encoded strings.
///
/// [encoder] encodes all bytes other than ASCII letters, decimal digits, or one
/// of `-._~`. This matches the behavior of [Uri.encodeQueryComponent] except
/// that it doesn't encode `0x20` bytes to the `+` character.
///
/// This will throw a [RangeError] if the byte array has any digits that don't
/// fit in the gamut of a byte.
class PercentEncoder extends Converter<List<int>, String> {
  const PercentEncoder._();

  String convert(List<int> bytes) => _convert(bytes, 0, bytes.length);

  ByteConversionSink startChunkedConversion(Sink<String> sink) =>
      new _PercentEncoderSink(sink);
}

/// A conversion sink for chunked percentadecimal encoding.
class _PercentEncoderSink extends ByteConversionSinkBase {
  /// The underlying sink to which decoded byte arrays will be passed.
  final Sink<String> _sink;

  _PercentEncoderSink(this._sink);

  void add(List<int> chunk) {
    _sink.add(_convert(chunk, 0, chunk.length));
  }

  void addSlice(List<int> chunk, int start, int end, bool isLast) {
    RangeError.checkValidRange(start, end, chunk.length);
    _sink.add(_convert(chunk, start, end));
    if (isLast) _sink.close();
  }

  void close() {
    _sink.close();
  }
}

String _convert(List<int> bytes, int start, int end) {
  var buffer = new StringBuffer();

  // A bitwise OR of all bytes in [bytes]. This allows us to check for
  // out-of-range bytes without adding more branches than necessary to the
  // core loop.
  var byteOr = 0;
  for (var i = start; i < end; i++) {
    var byte = bytes[i];
    byteOr |= byte;

    // If the byte is an uppercase letter, convert it to lowercase to check if
    // it's unreserved. This works because uppercase letters in ASCII are
    // exactly `0b100000 = 0x20` less than lowercase letters, so if we ensure
    // that that bit is 1 we ensure that the letter is lowercase.
    var letter = 0x20 | byte;
    if ((letter >= $a && letter <= $z) ||
        (byte >= $0 && byte <= $9) ||
        byte == $dash ||
        byte == $dot ||
        byte == $underscore ||
        byte == $tilde) {
      // Unreserved characters are safe to write as-is.
      buffer.writeCharCode(byte);
      continue;
    }

    buffer.writeCharCode($percent);

    // The bitwise arithmetic here is equivalent to `byte ~/ 16` and `byte % 16`
    // for valid byte values, but is easier for dart2js to optimize given that
    // it can't prove that [byte] will always be positive.
    buffer.writeCharCode(_codeUnitForDigit((byte & 0xF0) >> 4));
    buffer.writeCharCode(_codeUnitForDigit(byte & 0x0F));
  }

  if (byteOr >= 0 && byteOr <= 255) return buffer.toString();

  // If there was an invalid byte, find it and throw an exception.
  for (var i = start; i < end; i++) {
    var byte = bytes[i];
    if (byte >= 0 && byte <= 0xff) continue;
    throw new FormatException(
        "Invalid byte ${byte < 0 ? "-" : ""}0x${byte.abs().toRadixString(16)}.",
        bytes,
        i);
  }

  throw 'unreachable';
}

/// Returns the ASCII/Unicode code unit corresponding to the hexadecimal digit
/// [digit].
int _codeUnitForDigit(int digit) => digit < 10 ? digit + $0 : digit + $A - 10;
