// Copyright 2013 The Flutter Authors. 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:math';
import 'dart:typed_data';

import 'constants.dart';
import 'resource_record.dart';

// Offsets into the header. See https://tools.ietf.org/html/rfc1035.
const int _kIdOffset = 0;
const int _kFlagsOffset = 2;
const int _kQdcountOffset = 4;
const int _kAncountOffset = 6;
const int _kNscountOffset = 8;
const int _kArcountOffset = 10;
const int _kHeaderSize = 12;

/// Processes a DNS query name into a list of parts.
///
/// Will attempt to append 'local' if the name is something like '_http._tcp',
/// and '._tcp.local' if name is something like '_http'.
List<String> processDnsNameParts(String name) {
  final List<String> parts = name.split('.');
  if (parts.length == 1) {
    return <String>[parts[0], '_tcp', 'local'];
  } else if (parts.length == 2 && parts[1].startsWith('_')) {
    return <String>[parts[0], parts[1], 'local'];
  }

  return parts;
}

/// Encode an mDNS query packet.
///
/// The [type] parameter must be a valid [ResourceRecordType] value. The
/// [multicast] parameter must not be null.
///
/// This is a low level API; most consumers should prefer
/// [ResourceRecordQuery.encode], which offers some convenience wrappers around
/// selecting the correct [type] and setting the [name] parameter correctly.
List<int> encodeMDnsQuery(
  String name, {
  int type = ResourceRecordType.addressIPv4,
  bool multicast = true,
}) {
  assert(ResourceRecordType.debugAssertValid(type));

  final List<String> nameParts = processDnsNameParts(name);
  final List<List<int>> rawNameParts =
      nameParts.map<List<int>>((String part) => utf8.encode(part)).toList();

  // Calculate the size of the packet.
  int size = _kHeaderSize;
  for (int i = 0; i < rawNameParts.length; i++) {
    size += 1 + rawNameParts[i].length;
  }

  size += 1; // End with empty part
  size += 4; // Trailer (QTYPE and QCLASS).
  final Uint8List data = Uint8List(size);
  final ByteData packetByteData = ByteData.view(data.buffer);
  // Query identifier - just use 0.
  packetByteData.setUint16(_kIdOffset, 0);
  // Flags - 0 for query.
  packetByteData.setUint16(_kFlagsOffset, 0);
  // Query count.
  packetByteData.setUint16(_kQdcountOffset, 1);
  // Number of answers - 0 for query.
  packetByteData.setUint16(_kAncountOffset, 0);
  // Number of name server records - 0 for query.
  packetByteData.setUint16(_kNscountOffset, 0);
  // Number of resource records - 0 for query.
  packetByteData.setUint16(_kArcountOffset, 0);
  int offset = _kHeaderSize;
  for (int i = 0; i < rawNameParts.length; i++) {
    data[offset++] = rawNameParts[i].length;
    data.setRange(offset, offset + rawNameParts[i].length, rawNameParts[i]);
    offset += rawNameParts[i].length;
  }

  data[offset] = 0; // Empty part.
  offset++;
  packetByteData.setUint16(offset, type); // QTYPE.
  offset += 2;
  packetByteData.setUint16(
      offset,
      ResourceRecordClass.internet |
          (multicast ? QuestionType.multicast : QuestionType.unicast));

  return data;
}

/// Result of reading a Fully Qualified Domain Name (FQDN).
class _FQDNReadResult {
  /// Creates a new FQDN read result.
  _FQDNReadResult(this.fqdnParts, this.bytesRead);

  /// The raw parts of the FQDN.
  final List<String> fqdnParts;

  /// The bytes consumed from the packet for this FQDN.
  final int bytesRead;

  /// Returns the Fully Qualified Domain Name.
  String get fqdn => fqdnParts.join('.');

  @override
  String toString() => fqdn;
}

/// Reads a FQDN from raw packet data.
String readFQDN(List<int> packet, [int offset = 0]) {
  final Uint8List data =
      packet is Uint8List ? packet : Uint8List.fromList(packet);
  final ByteData byteData = ByteData.view(data.buffer);

  return _readFQDN(data, byteData, offset, data.length).fqdn;
}

// Read a FQDN at the given offset. Returns a pair with the FQDN
// parts and the number of bytes consumed.
//
// If decoding fails (e.g. due to an invalid packet) `null` is returned.
_FQDNReadResult _readFQDN(
    Uint8List data, ByteData byteData, int offset, int length) {
  void checkLength(int required) {
    if (length < required) {
      throw MDnsDecodeException(required);
    }
  }

  final List<String> parts = <String>[];
  final int prevOffset = offset;
  final List<int> offsetsToVisit = <int>[offset];
  int upperLimitOffset = offset;
  int highestOffsetRead = offset;

  while (offsetsToVisit.isNotEmpty) {
    offset = offsetsToVisit.removeLast();

    while (true) {
      // At least one byte is required.
      checkLength(offset + 1);
      // Check for compressed.
      if (data[offset] & 0xc0 == 0xc0) {
        // At least two bytes are required for a compressed FQDN (see RFC1035 section 4.1.4).
        checkLength(offset + 2);

        // A compressed FQDN has a new offset in the lower 14 bits.
        final int pointerDest = byteData.getUint16(offset) & ~0xc000;
        // Pointers can only point to prior occurances of some name.
        // This check also guards against pointers that form loops.
        if (pointerDest >= upperLimitOffset) {
          throw MDnsDecodeException(offset);
        }
        upperLimitOffset = pointerDest;
        offsetsToVisit.add(pointerDest);
        highestOffsetRead = max(highestOffsetRead, offset + 2);
        break;
      } else {
        // A normal FQDN part has a length and a UTF-8 encoded name
        // part. If the length is 0 this is the end of the FQDN.
        final int partLength = data[offset];
        offset++;
        if (partLength > 0) {
          checkLength(offset + partLength);
          final Uint8List partBytes =
              Uint8List.view(data.buffer, offset, partLength);
          offset += partLength;
          // According to the RFC, this is supposed to be utf-8 encoded, but
          // we should continue decoding even if it isn't to avoid dropping the
          // rest of the data, which might still be useful.
          parts.add(utf8.decode(partBytes, allowMalformed: true));
          highestOffsetRead = max(highestOffsetRead, offset);
        } else {
          highestOffsetRead = max(highestOffsetRead, offset);
          break;
        }
      }
    }
  }
  return _FQDNReadResult(parts, highestOffsetRead - prevOffset);
}

/// Decode an mDNS response packet.
///
/// If decoding fails (e.g. due to an invalid packet) `null` is returned.
///
/// See https://tools.ietf.org/html/rfc1035 for the format.
List<ResourceRecord>? decodeMDnsResponse(List<int> packet) {
  final int length = packet.length;
  if (length < _kHeaderSize) {
    return null;
  }

  final Uint8List data =
      packet is Uint8List ? packet : Uint8List.fromList(packet);
  final ByteData packetBytes = ByteData.view(data.buffer);

  final int answerCount = packetBytes.getUint16(_kAncountOffset);
  final int authorityCount = packetBytes.getUint16(_kNscountOffset);
  final int additionalCount = packetBytes.getUint16(_kArcountOffset);
  final int remainingCount = answerCount + authorityCount + additionalCount;

  if (remainingCount == 0) {
    return null;
  }

  final int questionCount = packetBytes.getUint16(_kQdcountOffset);
  int offset = _kHeaderSize;

  void checkLength(int required) {
    if (length < required) {
      throw MDnsDecodeException(required);
    }
  }

  ResourceRecord? readResourceRecord() {
    // First read the FQDN.
    final _FQDNReadResult result = _readFQDN(data, packetBytes, offset, length);
    final String fqdn = result.fqdn;
    offset += result.bytesRead;
    checkLength(offset + 2);
    final int type = packetBytes.getUint16(offset);
    offset += 2;
    // The first bit of the rrclass field is set to indicate that the answer is
    // unique and the querier should flush the cached answer for this name
    // (RFC 6762, Sec. 10.2). We ignore it for now since we don't cache answers.
    checkLength(offset + 2);
    final int resourceRecordClass = packetBytes.getUint16(offset) & 0x7fff;

    if (resourceRecordClass != ResourceRecordClass.internet) {
      // We do not support other classes.
      return null;
    }

    offset += 2;
    checkLength(offset + 4);
    final int ttl = packetBytes.getInt32(offset);
    offset += 4;

    checkLength(offset + 2);
    final int readDataLength = packetBytes.getUint16(offset);
    offset += 2;
    final int validUntil = DateTime.now().millisecondsSinceEpoch + ttl * 1000;
    switch (type) {
      case ResourceRecordType.addressIPv4:
        checkLength(offset + readDataLength);
        final StringBuffer addr = StringBuffer();
        final int stop = offset + readDataLength;
        addr.write(packetBytes.getUint8(offset));
        offset++;
        for (; offset < stop; offset++) {
          addr.write('.');
          addr.write(packetBytes.getUint8(offset));
        }
        return IPAddressResourceRecord(fqdn, validUntil,
            address: InternetAddress(addr.toString()));
      case ResourceRecordType.addressIPv6:
        checkLength(offset + readDataLength);
        final StringBuffer addr = StringBuffer();
        final int stop = offset + readDataLength;
        addr.write(packetBytes.getUint16(offset).toRadixString(16));
        offset += 2;
        for (; offset < stop; offset += 2) {
          addr.write(':');
          addr.write(packetBytes.getUint16(offset).toRadixString(16));
        }
        return IPAddressResourceRecord(
          fqdn,
          validUntil,
          address: InternetAddress(addr.toString()),
        );
      case ResourceRecordType.service:
        checkLength(offset + 2);
        final int priority = packetBytes.getUint16(offset);
        offset += 2;
        checkLength(offset + 2);
        final int weight = packetBytes.getUint16(offset);
        offset += 2;
        checkLength(offset + 2);
        final int port = packetBytes.getUint16(offset);
        offset += 2;
        final _FQDNReadResult result =
            _readFQDN(data, packetBytes, offset, length);
        offset += result.bytesRead;
        return SrvResourceRecord(
          fqdn,
          validUntil,
          target: result.fqdn,
          port: port,
          priority: priority,
          weight: weight,
        );
      case ResourceRecordType.serverPointer:
        checkLength(offset + readDataLength);
        final _FQDNReadResult result =
            _readFQDN(data, packetBytes, offset, length);
        offset += readDataLength;
        return PtrResourceRecord(
          fqdn,
          validUntil,
          domainName: result.fqdn,
        );
      case ResourceRecordType.text:
        checkLength(offset + readDataLength);
        // The first byte of the buffer is the length of the first string of
        // the TXT record. Further length-prefixed strings may follow. We
        // concatenate them with newlines.
        final StringBuffer strings = StringBuffer();
        int index = 0;
        while (index < readDataLength) {
          final int txtLength = data[offset + index];
          index++;
          if (txtLength == 0) {
            continue;
          }
          final String text = utf8.decode(
            Uint8List.view(data.buffer, offset + index, txtLength),
            allowMalformed: true,
          );
          strings.writeln(text);
          index += txtLength;
        }
        offset += readDataLength;
        return TxtResourceRecord(fqdn, validUntil, text: strings.toString());
      default:
        checkLength(offset + readDataLength);
        offset += readDataLength;
        return null;
    }
  }

  // This list can't be fixed length right now because we might get
  // resource record types we don't support, and consumers expect this list
  // to not have null entries.
  final List<ResourceRecord> result = <ResourceRecord>[];

  try {
    for (int i = 0; i < questionCount; i++) {
      final _FQDNReadResult result =
          _readFQDN(data, packetBytes, offset, length);
      offset += result.bytesRead;
      checkLength(offset + 4);
      offset += 4;
    }
    for (int i = 0; i < remainingCount; i++) {
      final ResourceRecord? record = readResourceRecord();
      if (record != null) {
        result.add(record);
      }
    }
  } on MDnsDecodeException {
    // If decoding fails return null.
    return null;
  }
  return result;
}

/// This exception is thrown by the decoder when the packet is invalid.
class MDnsDecodeException implements Exception {
  /// Creates a new MDnsDecodeException, indicating an error in decoding at the
  /// specified [offset].
  ///
  /// The [offset] parameter should not be null.
  const MDnsDecodeException(this.offset);

  /// The offset in the packet at which the exception occurred.
  final int offset;

  @override
  String toString() => 'Decoding error at $offset';
}
