library platform_foundation.http.response_context;

import 'dart:async';
import 'dart:convert';
import 'dart:convert' as c show json;
import 'dart:io' show BytesBuilder, Cookie;
import 'dart:typed_data';

import 'package:platform_routing/route.dart';
import 'package:file/file.dart';
import 'package:http_parser/http_parser.dart';
import 'package:mime/mime.dart';

import 'controller.dart';
import 'request_context.dart';
import 'server.dart' show Application;

final RegExp _straySlashes = RegExp(r'(^/+)|(/+$)');

/// A convenience wrapper around an outgoing HTTP request.
abstract class ResponseContext<RawResponse>
    implements StreamConsumer<List<int>>, StreamSink<List<int>>, StringSink {
  final Map properties = {};
  final CaseInsensitiveMap<String> _headers = CaseInsensitiveMap<String>.from(
      {'content-type': 'text/plain', 'server': 'Protevus3'});

  //final log = Logger('ResponseContext');

  Completer? _done;
  int _statusCode = 200;

  /// The [Application] instance that is sending a response.
  Application? app;

  /// Is `Transfer-Encoding` chunked?
  bool? chunked;

  /// Any and all cookies to be sent to the user.
  final List<Cookie> cookies = [];

  /// A set of [Converter] objects that can be used to encode response data.
  ///
  /// At most one encoder will ever be used to convert data.
  final Map<String, Converter<List<int>, List<int>>> encoders = {};

  /// A [Map] of data to inject when `res.render` is called.
  ///
  /// This can be used to reduce boilerplate when using templating engines.
  final Map<String, dynamic> renderParams = {};

  /// Points to the [RequestContext] corresponding to this response.
  RequestContext? get correspondingRequest;

  @override
  Future get done => (_done ?? Completer()).future;

  /// Headers that will be sent to the user.
  ///
  /// Note that if you have already started writing to the underlying stream, headers will not persist.
  CaseInsensitiveMap<String> get headers => _headers;

  /// Serializes response data into a String.
  ///
  /// The default is conversion into JSON via `json.encode`.
  ///
  /// If you are 100% sure that your response handlers will only
  /// be JSON-encodable objects (i.e. primitives, `List`s and `Map`s),
  /// then consider setting [serializer] to `JSON.encode`.
  ///
  /// To set it globally for the whole [app], use the following helper:
  /// ```dart
  /// app.injectSerializer(JSON.encode);
  /// ```
  FutureOr<String> Function(dynamic) serializer = c.json.encode;

  /// This response's status code.
  int get statusCode => _statusCode;

  set statusCode(int value) {
    if (!isOpen) {
      throw closed();
    } else {
      _statusCode = value; // ?? 200;
    }
  }

  /// Returns `true` if the response is still available for processing by Protevus.
  ///
  /// If it is `false`, then Protevus will stop executing handlers, and will only run
  /// response finalizers if the response [isBuffered].
  bool get isOpen;

  /// Returns `true` if response data is being written to a buffer, rather than to the underlying stream.
  bool get isBuffered;

  /// A set of UTF-8 encoded bytes that will be written to the response.
  BytesBuilder? get buffer;

  /// The underlying [RawResponse] under this instance.
  RawResponse get rawResponse;

  /// Signals Protevus that the response is being held alive deliberately, and that the framework should not automatically close it.
  ///
  /// This is mostly used in situations like WebSocket handlers, where the connection should remain
  /// open indefinitely.
  FutureOr<RawResponse> detach();

  /// Gets or sets the content length to send back to a client.
  ///
  /// Returns `null` if the header is invalidly formatted.
  int? get contentLength {
    return int.tryParse(headers['content-length'] ?? '-1');
  }

  /// Gets or sets the content length to send back to a client.
  ///
  /// If [value] is `null`, then the header will be removed.
  set contentLength(int? value) {
    if (value == null || value == -1) {
      headers.remove('content-length');
    } else {
      headers['content-length'] = value.toString();
    }
  }

  /// Gets or sets the content type to send back to a client.
  MediaType get contentType {
    try {
      return MediaType.parse(headers['content-type']!);
    } catch (_) {
      return MediaType('text', 'plain');
    }
  }

  /// Gets or sets the content type to send back to a client.
  set contentType(MediaType value) {
    headers['content-type'] = value.toString();
  }

  static StateError closed() => StateError('Cannot modify a closed response.');

  /// Sends a download as a response.
  Future<void> download(File file, {String? filename}) async {
    if (!isOpen) throw closed();

    headers['Content-Disposition'] =
        'attachment; filename="${filename ?? file.path}"';
    contentType = MediaType.parse(lookupMimeType(file.path)!);
    headers['content-length'] = file.lengthSync().toString();

    if (!isBuffered) {
      await file.openRead().cast<List<int>>().pipe(this);
    } else {
      buffer!.add(file.readAsBytesSync());
      await close();
    }
  }

  /// Prevents more data from being written to the response, and locks it entire from further editing.
  @override
  Future<void> close() {
    if (buffer is LockableBytesBuilder) {
      (buffer as LockableBytesBuilder).lock();
    }

    if (_done?.isCompleted == false) _done!.complete();
    return Future.value();
  }

  /// Serializes JSON to the response.
  Future<bool> json(value) =>
      serialize(value, contentType: MediaType('application', 'json'));

  /// Returns a JSONP response.
  ///
  /// You can override the [contentType] sent; by default it is `application/javascript`.
  Future<void> jsonp(value,
      {String callbackName = 'callback', MediaType? contentType}) {
    if (!isOpen) throw closed();
    this.contentType = contentType ?? MediaType('application', 'javascript');
    write('$callbackName(${serializer(value)})');
    return close();
  }

  /// Renders a view to the response stream, and closes the response.
  Future<void> render(String view, [Map<String, dynamic>? data]) {
    if (!isOpen) throw closed();
    contentType = MediaType('text', 'html', {'charset': 'utf-8'});
    return Future<String>.sync(() => app!.viewGenerator!(
        view,
        Map<String, dynamic>.from(renderParams)
          ..addAll(data ?? <String, dynamic>{}))).then((content) {
      write(content);
      return close();
    });
  }

  /// Redirects to user to the given URL.
  ///
  /// [url] can be a `String`, or a `List`.
  /// If it is a `List`, a URI will be constructed
  /// based on the provided params.
  ///
  /// See [Router]#navigate for more. :)
  Future<void> redirect(url, {bool absolute = true, int? code}) {
    if (!isOpen) throw closed();
    headers
      ..['content-type'] = 'text/html'
      ..['location'] = (url is String || url is Uri)
          ? url.toString()
          : app!.navigate(url as Iterable, absolute: absolute);
    statusCode = code ?? 302;
    write('''
    <!DOCTYPE html>
    <html>
      <head>
        <title>Redirecting...</title>
        <meta http-equiv="refresh" content="0; url=$url">
      </head>
      <body>
        <h1>Currently redirecting you...</h1>
        <br />
        Click <a href="$url">here</a> if you are not automatically redirected...
        <script>
          window.location = "$url";
        </script>
      </body>
    </html>
    ''');
    return close();
  }

  /// Redirects to the given named [Route].
  Future<void> redirectTo(String name, [Map? params, int? code]) async {
    if (!isOpen) throw closed();
    Route? findRoute(Router r) {
      for (var route in r.routes) {
        if (route is SymlinkRoute) {
          final m = findRoute(route.router);

          if (m != null) return m;
        } else if (route.name == name) {
          return route;
        }
      }

      return null;
    }

    var matched = findRoute(app!);

    if (matched != null) {
      await redirect(
          matched.makeUri(params!.keys.fold<Map<String, dynamic>>({}, (out, k) {
            return out..[k.toString()] = params[k];
          })),
          code: code);
      return;
    }

    throw ArgumentError.notNull('Route to redirect to ($name)');
  }

  /// Redirects to the given [Controller] action.
  Future<void> redirectToAction(String action, [Map? params, int? code]) {
    if (!isOpen) throw closed();
    // UserController@show
    var split = action.split('@');

    if (split.length < 2) {
      throw Exception(
          "Controller redirects must take the form of 'Controller@action'. You gave: $action");
    }

    var controller = app!.controllers[split[0].replaceAll(_straySlashes, '')];

    if (controller == null) {
      throw Exception("Could not find a controller named '${split[0]}'");
    }

    var matched = controller.routeMappings[split[1]];

    if (matched == null) {
      throw Exception(
          "Controller '${split[0]}' does not contain any action named '${split[1]}'");
    }

    final head = controller
        .findExpose(app!.container.reflector)!
        .path
        .toString()
        .replaceAll(_straySlashes, '');
    var tail = '';
    if (params != null) {
      tail = matched
          .makeUri(params.keys.fold<Map<String, dynamic>>({}, (out, k) {
            return out..[k.toString()] = params[k];
          }))
          .replaceAll(_straySlashes, '');
    }
    return redirect('$head/$tail'.replaceAll(_straySlashes, ''), code: code);
  }

  /// Serializes data to the response.
  Future<bool> serialize(value, {MediaType? contentType}) async {
    if (!isOpen) {
      throw closed();
    }
    this.contentType = contentType ?? MediaType('application', 'json');
    var text = await serializer(value);
    if (text.isEmpty) return true;
    write(text);
    await close();
    return false;
  }

  /// Streams a file to this response.
  ///
  /// `HEAD` responses will not actually write data.
  Future streamFile(File file) async {
    if (!isOpen) {
      throw closed();
    }
    var mimeType = app!.mimeTypeResolver.lookup(file.path);
    contentLength = await file.length();
    contentType = mimeType == null
        ? MediaType('application', 'octet-stream')
        : MediaType.parse(mimeType);

    if (correspondingRequest!.method != 'HEAD') {
      return addStream(file.openRead().cast<List<int>>()).then((_) => close());
    }
  }

  /// Configure the response to write to an intermediate response buffer, rather than to the stream directly.
  void useBuffer();

  /// Adds a stream directly the underlying response.
  ///
  /// If this instance has access to a [correspondingRequest], then it will attempt to transform
  /// the content using at most one of the response [encoders].
  @override
  Future addStream(Stream<List<int>> stream);

  @override
  void addError(Object error, [StackTrace? stackTrace]) {
    if (_done?.isCompleted == false) {
      _done!.completeError(error, stackTrace);
    } else if (_done == null) {
      if (stackTrace != null) {
        Zone.current.handleUncaughtError(error, stackTrace);
      } else {
        app?.logger.warning('[ResponseContext] stackTrace is null');
      }
    }
  }

  /// Writes data to the response.
  @override
  void write(value, {Encoding? encoding}) {
    encoding ??= utf8;

    if (!isOpen && isBuffered) {
      throw closed();
    } else if (!isBuffered) {
      add(encoding.encode(value.toString()));
    } else {
      buffer!.add(encoding.encode(value.toString()));
    }
  }

  @override
  void writeCharCode(int charCode) {
    if (!isOpen && isBuffered) {
      throw closed();
    } else if (!isBuffered) {
      add([charCode]);
    } else {
      buffer!.addByte(charCode);
    }
  }

  @override
  void writeln([Object? obj = '']) {
    write(obj.toString());
    write('\r\n');
  }

  @override
  void writeAll(Iterable objects, [String separator = '']) {
    write(objects.join(separator));
  }
}

abstract class LockableBytesBuilder implements BytesBuilder {
  factory LockableBytesBuilder() {
    return _LockableBytesBuilderImpl();
  }

  void lock();
}

class _LockableBytesBuilderImpl implements LockableBytesBuilder {
  final BytesBuilder _buf = BytesBuilder(copy: false);
  bool _closed = false;

  StateError _deny() =>
      StateError('Cannot modified a closed response\'s buffer.');

  @override
  void lock() {
    _closed = true;
  }

  @override
  void add(List<int> bytes) {
    if (_closed) {
      throw _deny();
    } else {
      _buf.add(bytes);
    }
  }

  @override
  void addByte(int byte) {
    if (_closed) {
      throw _deny();
    } else {
      _buf.addByte(byte);
    }
  }

  @override
  void clear() {
    _buf.clear();
  }

  @override
  bool get isEmpty => _buf.isEmpty;

  @override
  bool get isNotEmpty => _buf.isNotEmpty;

  @override
  int get length => _buf.length;

  @override
  Uint8List takeBytes() {
    return _buf.takeBytes();
  }

  @override
  Uint8List toBytes() {
    return _buf.toBytes();
  }
}
