import 'dart:collection';
import 'dart:convert' show ascii, utf8;

import 'package:source_span/source_span.dart';

import 'constants.dart';
import 'encoding_parser.dart';
import 'utils.dart';

/// Provides a unicode stream of characters to the HtmlTokenizer.
///
/// This class takes care of character encoding and removing or replacing
/// incorrect byte-sequences and also provides column and line tracking.
class HtmlInputStream {
  /// Number of bytes to use when looking for a meta element with
  /// encoding information.
  static const int numBytesMeta = 512;

  /// Encoding to use if no other information can be found.
  static const String defaultEncoding = 'utf-8';

  /// The name of the character encoding.
  String? charEncodingName;

  /// True if we are certain about [charEncodingName], false for tentative.
  bool charEncodingCertain = true;

  final bool generateSpans;

  /// Location where the contents of the stream were found.
  final String? sourceUrl;

  List<int>? _rawBytes;

  /// Raw UTF-16 codes, used if a Dart String is passed in.
  List<int>? _rawChars;

  Queue<String> errors = Queue<String>();

  SourceFile? fileInfo;

  var _chars = <int>[];

  var _offset = 0;

  /// Initialise an HtmlInputStream.
  ///
  /// HtmlInputStream(source, [encoding]) -> Normalized stream from source
  /// for use by html5lib.
  ///
  /// [source] can be either a `String` or a `List<int>` containing the raw
  /// bytes.
  ///
  /// The optional encoding parameter must be a string that indicates
  /// the encoding.  If specified, that encoding will be used,
  /// regardless of any BOM or later declaration (such as in a meta
  /// element)
  ///
  /// [parseMeta] - Look for a <meta> element containing encoding information
  HtmlInputStream(dynamic source,
      [String? encoding,
      bool parseMeta = true,
      this.generateSpans = false,
      this.sourceUrl])
      : charEncodingName = codecName(encoding) {
    if (source is String) {
      _rawChars = source.codeUnits;
      charEncodingName = 'utf-8';
      charEncodingCertain = true;
    } else if (source is List<int>) {
      _rawBytes = source;
    } else {
      throw ArgumentError.value(
          source, 'source', 'Must be a String or List<int>.');
    }

    // Detect encoding iff no explicit "transport level" encoding is supplied
    if (charEncodingName == null) {
      detectEncoding(parseMeta);
    }

    reset();
  }

  void reset() {
    errors = Queue<String>();

    _offset = 0;

    final rawChars = _rawChars ??= _decodeBytes(charEncodingName!, _rawBytes!);

    // Optimistically allocate array, trim it later if there are changes
    _chars = List.filled(rawChars.length, 0, growable: true);
    var skipNewline = false;
    var wasSurrogatePair = false;
    var deletedChars = 0;

    /// CodeUnits.length is not free
    final charsLength = rawChars.length;
    for (var i = 0; i < charsLength; i++) {
      var c = rawChars[i];
      if (skipNewline) {
        skipNewline = false;
        if (c == Charcode.lineFeed) {
          deletedChars++;
          continue;
        }
      }

      final isSurrogatePair = _isLeadSurrogate(c) &&
          i + 1 < charsLength &&
          _isTrailSurrogate(rawChars[i + 1]);
      if (!isSurrogatePair && !wasSurrogatePair) {
        if (_invalidUnicode(c)) {
          errors.add('invalid-codepoint');

          if (0xD800 <= c && c <= 0xDFFF) {
            c = 0xFFFD;
          }
        }
      }
      wasSurrogatePair = isSurrogatePair;

      if (c == Charcode.carriageReturn) {
        skipNewline = true;
        c = Charcode.lineFeed;
      }

      _chars[i - deletedChars] = c;
    }
    if (deletedChars > 0) {
      // Remove the null bytes from the end
      _chars.removeRange(_chars.length - deletedChars, _chars.length);
    }

    // Free decoded characters if they aren't needed anymore.
    if (_rawBytes != null) _rawChars = null;

    if (generateSpans) {
      fileInfo = SourceFile.decoded(_chars, url: sourceUrl);
    }
  }

  void detectEncoding([bool parseMeta = true]) {
    // First look for a BOM
    // This will also read past the BOM if present
    charEncodingName = detectBOM();
    charEncodingCertain = true;

    // If there is no BOM need to look for meta elements with encoding
    // information
    if (charEncodingName == null && parseMeta) {
      charEncodingName = detectEncodingMeta();
      charEncodingCertain = false;
    }
    // If all else fails use the default encoding
    if (charEncodingName == null) {
      charEncodingCertain = false;
      charEncodingName = defaultEncoding;
    }

    // Substitute for equivalent encodings:
    if (charEncodingName!.toLowerCase() == 'iso-8859-1') {
      charEncodingName = 'windows-1252';
    }
  }

  void changeEncoding(String? newEncoding) {
    if (_rawBytes == null) {
      // We should never get here -- if encoding is certain we won't try to
      // change it.
      throw StateError('cannot change encoding when parsing a String.');
    }

    newEncoding = codecName(newEncoding);
    if (const ['utf-16', 'utf-16-be', 'utf-16-le'].contains(newEncoding)) {
      newEncoding = 'utf-8';
    }
    if (newEncoding == null) {
      return;
    } else if (newEncoding == charEncodingName) {
      charEncodingCertain = true;
    } else {
      charEncodingName = newEncoding;
      charEncodingCertain = true;
      _rawChars = null;
      reset();
      throw ReparseException(
          'Encoding changed from $charEncodingName to $newEncoding');
    }
  }

  /// Attempts to detect at BOM at the start of the stream. If
  /// an encoding can be determined from the BOM return the name of the
  /// encoding otherwise return null.
  String? detectBOM() {
    // Try detecting the BOM using bytes from the string
    if (_hasUtf8Bom(_rawBytes!)) {
      return 'utf-8';
    }
    return null;
  }

  /// Report the encoding declared by the meta element.
  String? detectEncodingMeta() {
    final parser = EncodingParser(slice(_rawBytes!, 0, numBytesMeta));
    var encoding = parser.getEncoding();

    if (const ['utf-16', 'utf-16-be', 'utf-16-le'].contains(encoding)) {
      encoding = 'utf-8';
    }

    return encoding;
  }

  /// Returns the current offset in the stream, i.e. the number of codepoints
  /// since the start of the file.
  int get position => _offset;

  /// Read one character from the stream or queue if available. Return
  /// EOF when EOF is reached.
  String? char() {
    if (_offset >= _chars.length) return eof;
    final firstCharCode = _chars[_offset++];
    if (firstCharCode < 256) {
      return asciiCharacters[firstCharCode];
    }
    if (_isSurrogatePair(_chars, _offset - 1)) {
      return String.fromCharCodes([firstCharCode, _chars[_offset++]]);
    }
    return String.fromCharCode(firstCharCode);
  }

  int? peekCodeUnit() {
    if (_offset >= _chars.length) return null;
    return _chars[_offset];
  }

  String? peekChar() {
    if (_offset >= _chars.length) return eof;
    final firstCharCode = _chars[_offset];
    if (firstCharCode < 256) {
      return asciiCharacters[firstCharCode];
    }
    if (_isSurrogatePair(_chars, _offset)) {
      return String.fromCharCodes([firstCharCode, _chars[_offset + 1]]);
    }
    return String.fromCharCode(firstCharCode);
  }

  // Whether the current and next chars indicate a surrogate pair.
  bool _isSurrogatePair(List<int> chars, int i) {
    return i + 1 < chars.length &&
        _isLeadSurrogate(chars[i]) &&
        _isTrailSurrogate(chars[i + 1]);
  }

  // Is then code (a 16-bit unsigned integer) a UTF-16 lead surrogate.
  bool _isLeadSurrogate(int code) => (code & 0xFC00) == 0xD800;

  // Is then code (a 16-bit unsigned integer) a UTF-16 trail surrogate.
  bool _isTrailSurrogate(int code) => (code & 0xFC00) == 0xDC00;

  /// Returns a string of characters from the stream up to but not
  /// including any character in 'characters' or EOF. These functions rely
  /// on the charCode(s) being single-codepoint.
  String charsUntil(Set<int> charCodes, [bool opposite = false]) {
    final start = _offset;
    int? c;
    while ((c = peekCodeUnit()) != null && charCodes.contains(c!) == opposite) {
      _offset += 1;
    }

    return String.fromCharCodes(_chars.sublist(start, _offset));
  }

  String charsUntil1(int charCode, [bool opposite = false]) {
    final start = _offset;
    int? c;
    while ((c = peekCodeUnit()) != null && (charCode == c!) == opposite) {
      _offset += 1;
    }

    return String.fromCharCodes(_chars.sublist(start, _offset));
  }

  String charsUntil2(int charCode1, int charCode2, [bool opposite = false]) {
    final start = _offset;
    int? c;
    while ((c = peekCodeUnit()) != null &&
        (charCode1 == c! || charCode2 == c) == opposite) {
      _offset += 1;
    }

    return String.fromCharCodes(_chars.sublist(start, _offset));
  }

  String charsUntil3(int charCode1, int charCode2, int charCode3,
      [bool opposite = false]) {
    final start = _offset;
    int? c;
    while ((c = peekCodeUnit()) != null &&
        (charCode1 == c! || charCode2 == c || charCode3 == c) == opposite) {
      _offset += 1;
    }

    return String.fromCharCodes(_chars.sublist(start, _offset));
  }

  String charsUntilAsciiLetter([bool opposite = false]) {
    final start = _offset;
    int? c;
    while ((c = peekCodeUnit()) != null &&
        ((c! >= Charcode.upperA && c <= Charcode.upperZ) ||
                (c >= Charcode.lowerA && c <= Charcode.lowerZ)) ==
            opposite) {
      _offset += 1;
    }
    return String.fromCharCodes(_chars.sublist(start, _offset));
  }

  static bool _isSpaceCharacter(int c) =>
      c == Charcode.space ||
      c == Charcode.lineFeed ||
      c == Charcode.carriageReturn ||
      c == Charcode.tab ||
      c == Charcode.formFeed;

  String charsUntilSpace([bool opposite = false]) {
    final start = _offset;
    int? c;
    while ((c = peekCodeUnit()) != null && _isSpaceCharacter(c!) == opposite) {
      _offset += 1;
    }

    return String.fromCharCodes(_chars.sublist(start, _offset));
  }

  void unget(String? ch) {
    // Only one character is allowed to be ungotten at once - it must
    // be consumed again before any further call to unget
    if (ch != null) {
      _offset -= ch.length;
      assert(peekChar() == ch);
    }
  }
}

// TODO(jmesserly): the Python code used a regex to check for this. But
// Dart doesn't let you create a regexp with invalid characters.
bool _invalidUnicode(int c) {
  // Fast return for common ASCII characters
  if (0x0020 <= c && c <= 0x007E) return false;
  if (0x0001 <= c && c <= 0x0008) return true;
  if (0x000E <= c && c <= 0x001F) return true;
  if (0x007F <= c && c <= 0x009F) return true;
  if (0xD800 <= c && c <= 0xDFFF) return true;
  if (0xFDD0 <= c && c <= 0xFDEF) return true;
  switch (c) {
    case 0x000B:
    case 0xFFFE:
    case 0xFFFF:
    case 0x01FFFE:
    case 0x01FFFF:
    case 0x02FFFE:
    case 0x02FFFF:
    case 0x03FFFE:
    case 0x03FFFF:
    case 0x04FFFE:
    case 0x04FFFF:
    case 0x05FFFE:
    case 0x05FFFF:
    case 0x06FFFE:
    case 0x06FFFF:
    case 0x07FFFE:
    case 0x07FFFF:
    case 0x08FFFE:
    case 0x08FFFF:
    case 0x09FFFE:
    case 0x09FFFF:
    case 0x0AFFFE:
    case 0x0AFFFF:
    case 0x0BFFFE:
    case 0x0BFFFF:
    case 0x0CFFFE:
    case 0x0CFFFF:
    case 0x0DFFFE:
    case 0x0DFFFF:
    case 0x0EFFFE:
    case 0x0EFFFF:
    case 0x0FFFFE:
    case 0x0FFFFF:
    case 0x10FFFE:
    case 0x10FFFF:
      return true;
  }
  return false;
}

/// Return the python codec name corresponding to an encoding or null if the
/// string doesn't correspond to a valid encoding.
String? codecName(String? encoding) {
  final asciiPunctuation = RegExp(
      '[\u0009-\u000D\u0020-\u002F\u003A-\u0040\u005B-\u0060\u007B-\u007E]');

  if (encoding == null) return null;
  final canonicalName = encoding.replaceAll(asciiPunctuation, '').toLowerCase();
  return encodings[canonicalName];
}

/// Returns true if the [bytes] starts with a UTF-8 byte order mark.
/// Since UTF-8 doesn't have byte order, it's somewhat of a misnomer, but it is
/// used in HTML to detect the UTF-
bool _hasUtf8Bom(List<int> bytes, [int offset = 0, int? length]) {
  final end = length != null ? offset + length : bytes.length;
  return (offset + 3) <= end &&
      bytes[offset] == 0xEF &&
      bytes[offset + 1] == 0xBB &&
      bytes[offset + 2] == 0xBF;
}

/// Decodes the [bytes] with the provided [encoding] and returns a list for
/// the codepoints. Supports the major unicode encodings as well as ascii and
/// and windows-1252 encodings.
List<int> _decodeBytes(String encoding, List<int> bytes) {
  return switch (encoding) {
    'ascii' => ascii.decode(bytes).codeUnits,
    'utf-8' =>
      // NOTE: To match the behavior of the other decode functions, we eat the
      // UTF-8 BOM here. This is the default behavior of `utf8.decode`.
      utf8.decode(bytes).codeUnits,
    _ => throw ArgumentError('Encoding $encoding not supported')
  };
}
