import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'dart:typed_data';

import 'package:flutter/foundation.dart' show visibleForTesting;

const int scriptHttpMaximumResponseBytes = 10 * 1024 * 1024;
const int _maximumRedirects = 5;
const Duration _connectTimeout = Duration(seconds: 15);
const Duration _receiveTimeout = Duration(seconds: 30);

class SafeScriptHttpResponse {
  final Object? data;
  final String text;
  final int statusCode;
  final String reasonPhrase;
  final Map<String, String> headers;

  const SafeScriptHttpResponse({
    required this.data,
    required this.text,
    required this.statusCode,
    required this.reasonPhrase,
    required this.headers,
  });
}

class SafeScriptHttpException implements Exception {
  final String message;

  const SafeScriptHttpException(this.message);

  @override
  String toString() => message;
}

class SafeScriptHttpClient {
  Future<SafeScriptHttpResponse> request({
    required Uri uri,
    String method = 'GET',
    Map<String, String> headers = const <String, String>{},
    Map<String, Object?> queryParameters = const <String, Object?>{},
    Object? body,
    bool followRedirects = true,
  }) async {
    var currentUri = _appendQueryParameters(uri, queryParameters);
    var currentMethod = method.toUpperCase();
    if (!_allowedMethods.contains(currentMethod)) {
      throw const SafeScriptHttpException('HTTP method is not permitted');
    }
    var currentHeaders = _sanitizeRequestHeaders(headers);
    var currentBody = body;

    for (var redirectCount = 0; ; redirectCount++) {
      final addresses = await _resolvePublicAddresses(currentUri);
      final httpClient = _createPinnedHttpClient(currentUri, addresses.first);
      try {
        final request = await httpClient.openUrl(currentMethod, currentUri);
        request.followRedirects = false;
        currentHeaders.forEach(request.headers.set);
        _writeRequestBody(request, currentBody, currentHeaders);

        final response = await request.close().timeout(_receiveTimeout);
        final location = response.headers.value(HttpHeaders.locationHeader);
        final shouldRedirect =
            followRedirects &&
            location != null &&
            response.isRedirect;
        if (shouldRedirect) {
          if (redirectCount >= _maximumRedirects) {
            throw const SafeScriptHttpException('HTTP redirect limit exceeded');
          }
          final redirectedUri = currentUri.resolve(location);
          final changesOrigin = !_hasSameOrigin(currentUri, redirectedUri);
          if (changesOrigin) {
            currentHeaders = _removeSensitiveHeaders(currentHeaders);
          }
          if (_redirectsAsGet(response.statusCode, currentMethod)) {
            currentMethod = 'GET';
            currentBody = null;
            currentHeaders = _removeEntityHeaders(currentHeaders);
          }
          currentUri = redirectedUri;
          continue;
        }

        final responseBytes = await _readLimitedResponse(response);
        final responseText = _decodeResponseText(responseBytes, response);
        return SafeScriptHttpResponse(
          data: _decodeResponseData(responseText, response),
          text: responseText,
          statusCode: response.statusCode,
          reasonPhrase: response.reasonPhrase,
          headers: _responseHeaders(response),
        );
      } finally {
        httpClient.close(force: true);
      }
    }
  }
}

HttpClient _createPinnedHttpClient(Uri uri, InternetAddress address) {
  final httpClient = HttpClient()
    ..connectionTimeout = _connectTimeout
    ..idleTimeout = _receiveTimeout
    ..findProxy = (_) => 'DIRECT';
  httpClient.connectionFactory =
      (Uri connectionUri, String? proxyHost, int? proxyPort) async {
        if (proxyHost != null || proxyPort != null) {
          throw const SafeScriptHttpException('HTTP proxies are not permitted');
        }
        if (!_hasSameOrigin(uri, connectionUri)) {
          throw const SafeScriptHttpException('Unexpected connection target');
        }
        final socketTask = await Socket.startConnect(
          address,
          connectionUri.port,
        );
        if (connectionUri.scheme != 'https') return socketTask;

        final Future<Socket> secureSocket = socketTask.socket.then(
          (socket) => SecureSocket.secure(socket, host: connectionUri.host),
        );
        return ConnectionTask.fromSocket<Socket>(
          secureSocket,
          socketTask.cancel,
        );
      };
  return httpClient;
}

Future<List<InternetAddress>> _resolvePublicAddresses(Uri uri) async {
  if (!isValidScriptHttpUri(uri)) {
    throw const SafeScriptHttpException(
      'Only valid HTTP(S) URLs are permitted',
    );
  }

  final literalAddress = InternetAddress.tryParse(uri.host);
  final resolvedAddresses = literalAddress == null
      ? await InternetAddress.lookup(uri.host).timeout(_connectTimeout)
      : <InternetAddress>[literalAddress];
  return filterPublicScriptAddresses(host: uri.host, addresses: resolvedAddresses);
}


@visibleForTesting
List<InternetAddress> filterPublicScriptAddresses({
  required String host,
  required List<InternetAddress> addresses,
}) {
  if (addresses.isEmpty) {
    throw SafeScriptHttpException('Could not resolve host: $host');
  }
  final publicAddresses = addresses
      .where(isPublicScriptAddress)
      .toList(growable: false);
  if (publicAddresses.isEmpty) {
    final rejectedAddresses = addresses
        .map((address) => address.address)
        .join(', ');
    throw SafeScriptHttpException(
      'Private or special-purpose network addresses are not permitted '
      '(host=$host, addresses=$rejectedAddresses)',
    );
  }
  return publicAddresses;
}

bool isPublicScriptAddress(InternetAddress address) {
  final bytes = address.rawAddress;
  if (address.type == InternetAddressType.IPv4) {
    return _isPublicIpv4(bytes);
  }
  if (address.type != InternetAddressType.IPv6 || bytes.length != 16) {
    return false;
  }


  final isIpv4Mapped =
      bytes.take(10).every((byte) => byte == 0) &&
      bytes[10] == 0xff &&
      bytes[11] == 0xff;
  if (isIpv4Mapped) return _isPublicIpv4(bytes.sublist(12));


  final isUnspecifiedOrLoopback = bytes
      .take(15)
      .every((byte) => byte == 0) && (bytes[15] == 0 || bytes[15] == 1);
  if (isUnspecifiedOrLoopback) return false;


  if ((bytes[0] & 0xfe) == 0xfc) return false;


  if (bytes[0] == 0xfe && (bytes[1] & 0xc0) == 0x80) return false;


  final isGlobalUnicast = (bytes[0] & 0xe0) == 0x20;
  if (!isGlobalUnicast) return false;


  return true;
}

bool _isPublicIpv4(List<int> bytes) {
  if (bytes.length != 4) return false;
  final first = bytes[0];
  final second = bytes[1];
  final third = bytes[2];


  if (first == 0 || first == 10 || first == 127) return false;
  if (first == 169 && second == 254) return false;
  if (first == 172 && second >= 16 && second <= 31) return false;
  if (first == 192 && second == 0 && third == 0) return false;
  if (first == 192 && second == 168) return false;
  if (first >= 224) return false;


  return true;
}

Future<Uint8List> _readLimitedResponse(HttpClientResponse response) async {
  return collectScriptResponseBytes(
    response.timeout(_receiveTimeout),
    declaredLength: response.contentLength,
  );
}

Future<Uint8List> collectScriptResponseBytes(
  Stream<List<int>> responseStream, {
  int declaredLength = -1,
  int maximumBytes = scriptHttpMaximumResponseBytes,
}) async {
  if (declaredLength > maximumBytes) {
    throw const SafeScriptHttpException('HTTP response exceeds 10 MB');
  }

  final builder = BytesBuilder(copy: false);
  var receivedBytes = 0;
  await for (final chunk in responseStream) {
    receivedBytes += chunk.length;
    if (receivedBytes > maximumBytes) {
      throw const SafeScriptHttpException('HTTP response exceeds 10 MB');
    }
    builder.add(chunk);
  }
  return builder.takeBytes();
}

void _writeRequestBody(
  HttpClientRequest request,
  Object? body,
  Map<String, String> headers,
) {
  if (body == null) return;
  if (body is List<int>) {
    request.add(body);
    return;
  }
  if (body is String) {
    request.write(body);
    return;
  }
  final hasContentType = headers.keys.any(
    (name) => name.toLowerCase() == HttpHeaders.contentTypeHeader,
  );
  if (!hasContentType) {
    request.headers.contentType = ContentType.json;
  }
  request.write(jsonEncode(body));
}

String _decodeResponseText(
  Uint8List responseBytes,
  HttpClientResponse response,
) {
  final charset = response.headers.contentType?.charset?.toLowerCase();
  if (charset == 'iso-8859-1' || charset == 'latin1') {
    return latin1.decode(responseBytes);
  }
  return utf8.decode(responseBytes, allowMalformed: true);
}

Object? _decodeResponseData(String responseText, HttpClientResponse response) {
  if (responseText.isEmpty) return responseText;
  final mimeType = response.headers.contentType?.mimeType.toLowerCase() ?? '';
  final declaredAsJson =
      mimeType == 'application/json' || mimeType.endsWith('+json');
  final looksLikeJson =
      responseText.startsWith('{') || responseText.startsWith('[');
  if (!declaredAsJson && !looksLikeJson) return responseText;
  try {
    return jsonDecode(responseText);
  } catch (_) {
    return responseText;
  }
}

Map<String, String> _responseHeaders(HttpClientResponse response) {
  final headers = <String, String>{};
  response.headers.forEach((name, values) {
    headers[name] = values.join(', ');
  });
  return headers;
}

Map<String, String> _sanitizeRequestHeaders(Map<String, String> headers) {
  return _removeHeaders(headers, const <String>{
    'connection',
    'content-length',
    'host',
    'proxy-authorization',
    'proxy-connection',
    'transfer-encoding',
    'upgrade',
  });
}

Map<String, String> _removeSensitiveHeaders(Map<String, String> headers) {
  return _removeHeaders(headers, const <String>{'authorization', 'cookie'});
}

Map<String, String> _removeEntityHeaders(Map<String, String> headers) {
  return _removeHeaders(headers, const <String>{
    'content-encoding',
    'content-language',
    'content-type',
  });
}

Map<String, String> _removeHeaders(
  Map<String, String> headers,
  Set<String> excludedNames,
) {
  return <String, String>{
    for (final entry in headers.entries)
      if (!excludedNames.contains(entry.key.toLowerCase()))
        entry.key: entry.value,
  };
}

Uri _appendQueryParameters(Uri uri, Map<String, Object?> queryParameters) {
  if (queryParameters.isEmpty) return uri;
  return uri.replace(
    queryParameters: <String, dynamic>{
      ...uri.queryParametersAll,
      for (final entry in queryParameters.entries)
        if (entry.value != null)
          entry.key: entry.value is Iterable
              ? (entry.value! as Iterable<Object?>)
                    .map((value) => value?.toString() ?? '')
                    .toList(growable: false)
              : entry.value.toString(),
    },
  );
}

bool isValidScriptHttpUri(Uri uri) {
  return (uri.scheme == 'http' || uri.scheme == 'https') &&
      uri.hasAuthority &&
      uri.host.isNotEmpty &&
      uri.userInfo.isEmpty;
}

const Set<String> _allowedMethods = <String>{
  'GET',
  'POST',
  'PUT',
  'PATCH',
  'DELETE',
  'HEAD',
  'OPTIONS',
};

bool _redirectsAsGet(int statusCode, String method) {
  return statusCode == HttpStatus.seeOther ||
      ((statusCode == HttpStatus.movedPermanently ||
              statusCode == HttpStatus.found) &&
          method == 'POST');
}

bool _hasSameOrigin(Uri left, Uri right) {
  return left.scheme == right.scheme &&
      left.host.toLowerCase() == right.host.toLowerCase() &&
      left.port == right.port;
}
