library angel_framework.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:angel_route/angel_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 Angel;

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': 'angel',
  });

  Completer _done;
  int _statusCode = 200;

  /// The [Angel] instance that is sending a response.
  Angel 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 `package:json_god`.
  ///
  /// 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 Angel.
  ///
  /// If it is `false`, then Angel 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 Angel 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']);
  }

  /// 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) {
      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.
  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.
  void json(value) => this
    ..contentType = MediaType('application', 'json')
    ..serialize(value);

  /// 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 = 302}) {
    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 (Route 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;
    }

    Route 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
    List<String> split = action.split("@");

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

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

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

    Route 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, '');
    final 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 this
          .addStream(file.openRead().cast<List<int>>())
          .then((_) => this.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) {
      Zone.current.handleUncaughtError(error, stackTrace);
    }
  }

  /// Writes data to the response.
  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 extends 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();
  }
}