// TODO(stuartmorgan): Remove this. See https://github.com/flutter/flutter/issues/174722.
// ignore_for_file: public_member_api_docs

import '../mustache.dart' as m;
import 'lambda_context.dart';
import 'node.dart';
import 'template.dart';
import 'template_exception.dart';

const Object noSuchProperty = Object();
final RegExp _integerTag = RegExp(r'^[0-9]+$');

class Renderer extends Visitor {
  Renderer(
    this.sink,
    List<Object?> stack,
    this.lenient,
    this.htmlEscapeValues,
    this.partialResolver,
    this.templateName,
    this.indent,
    this.source,
  ) : _stack = List<Object?>.from(stack);

  Renderer.partial(Renderer ctx, Template partial, String indent)
    : this(
        ctx.sink,
        ctx._stack,
        ctx.lenient,
        ctx.htmlEscapeValues,
        ctx.partialResolver,
        ctx.templateName,
        ctx.indent + indent,
        partial.source,
      );

  Renderer.subtree(Renderer ctx, StringSink sink)
    : this(
        sink,
        ctx._stack,
        ctx.lenient,
        ctx.htmlEscapeValues,
        ctx.partialResolver,
        ctx.templateName,
        ctx.indent,
        ctx.source,
      );

  Renderer.lambda(Renderer ctx, String source, String indent, StringSink sink)
    : this(
        sink,
        ctx._stack,
        ctx.lenient,
        ctx.htmlEscapeValues,
        ctx.partialResolver,
        ctx.templateName,
        ctx.indent + indent,
        source,
      );

  final StringSink sink;
  final List<Object?> _stack;
  final bool lenient;
  final bool htmlEscapeValues;
  final m.PartialResolver? partialResolver;
  final String? templateName;
  final String indent;
  final String source;

  void push(Object? value) => _stack.add(value);

  Object? pop() => _stack.removeLast();

  void write(Object output) => sink.write(output.toString());

  void render(List<Node> nodes) {
    if (indent == '') {
      for (final Node n in nodes) {
        n.accept(this);
      }
    } else if (nodes.isNotEmpty) {
      // Special case to make sure there is not an extra indent after the last
      // line in the partial file.
      write(indent);

      nodes.take(nodes.length - 1).forEach((Node n) => n.accept(this));

      final Node node = nodes.last;
      if (node is TextNode) {
        visitText(node, lastNode: true);
      } else {
        node.accept(this);
      }
    }
  }

  @override
  void visitText(TextNode node, {bool lastNode = false}) {
    if (node.text == '') {
      return;
    }
    if (indent == '') {
      write(node.text);
    } else if (lastNode && node.text.runes.last == _NEWLINE) {
      // Don't indent after the last line in a template.
      final String s = node.text.substring(0, node.text.length - 1);
      write(s.replaceAll('\n', '\n$indent'));
      write('\n');
    } else {
      write(node.text.replaceAll('\n', '\n$indent'));
    }
  }

  @override
  void visitVariable(VariableNode node) {
    Object? value = resolveValue(node.name);

    if (value is Function) {
      final LambdaContext context = LambdaContext(node, this);
      final Function valueFunction = value;
      // TODO(stuartmorgan): Add function typing in a way that doesn't break
      //  backward compatibility.
      // ignore: avoid_dynamic_calls
      value = valueFunction(context);
      context.close();
    }

    if (value == noSuchProperty) {
      if (!lenient) {
        throw error('Value was missing for variable tag: ${node.name}.', node);
      }
    } else {
      final String valueString = (value == null) ? '' : value.toString();
      final String output =
          !node.escape || !htmlEscapeValues
              ? valueString
              : _htmlEscape(valueString);
      write(output);
    }
  }

  @override
  void visitSection(SectionNode node) {
    if (node.inverse) {
      _renderInvSection(node);
    } else {
      _renderSection(node);
    }
  }

  void _renderSection(SectionNode node) {
    final Object? value = resolveValue(node.name);

    if (value == null) {
      // Do nothing.
    } else if (value is Iterable) {
      for (final Object? v in value) {
        _renderWithValue(node, v);
      }
    } else if (value is Map) {
      _renderWithValue(node, value);
    } else if (value == true) {
      _renderWithValue(node, value);
    } else if (value == false) {
      // Do nothing.
    } else if (value == noSuchProperty) {
      if (!lenient) {
        throw error('Value was missing for section tag: ${node.name}.', node);
      }
    } else if (value is Function) {
      final LambdaContext context = LambdaContext(node, this);
      // TODO(stuartmorgan): Add function typing in a way that doesn't break
      //  backward compatibility.
      // ignore: avoid_dynamic_calls
      final Object? output = value(context);
      context.close();
      if (output != null) {
        write(output);
      }
    } else {
      // Assume the value might have accessible member values via mirrors.
      _renderWithValue(node, value);
    }
  }

  void _renderInvSection(SectionNode node) {
    final Object? value = resolveValue(node.name);

    if (value == null) {
      _renderWithValue(node, null);
    } else if ((value is Iterable && value.isEmpty) || value == false) {
      _renderWithValue(node, node.name);
    } else if (value == true || value is Map || value is Iterable) {
      // Do nothing.
    } else if (value == noSuchProperty) {
      if (lenient) {
        _renderWithValue(node, null);
      } else {
        throw error(
          'Value was missing for inverse section: ${node.name}.',
          node,
        );
      }
    } else if (value is Function) {
      // Do nothing.
      // TODO(stuartmorgan): Determine whether this should be an error in
      //  strict mode (per comment in initial source import).
    } else if (lenient) {
      // We consider all other values as 'true' in lenient mode. Since this
      // is an inverted section, we do nothing.
    } else {
      throw error(
        'Invalid value type for inverse section, '
        'section: ${node.name}, '
        'type: ${value.runtimeType}.',
        node,
      );
    }
  }

  void _renderWithValue(SectionNode node, Object? value) {
    push(value);
    node.visitChildren(this);
    pop();
  }

  @override
  void visitPartial(PartialNode node) {
    final String partialName = node.name;
    final Template? template =
        partialResolver == null
            ? null
            : (partialResolver!(partialName) as Template?);
    if (template != null) {
      final Renderer renderer = Renderer.partial(this, template, node.indent);
      final List<Node> nodes = getTemplateNodes(template);
      renderer.render(nodes);
    } else if (lenient) {
      // do nothing
    } else {
      throw error('Partial not found: $partialName.', node);
    }
  }

  // Walks up the stack looking for the variable.
  // Handles dotted names of the form "a.b.c".
  Object? resolveValue(String name) {
    if (name == '.') {
      return _stack.last;
    }
    final List<String> parts = name.split('.');
    Object? object = noSuchProperty;
    for (final Object? o in _stack.reversed) {
      object = _getNamedProperty(o, parts[0]);
      if (object != noSuchProperty) {
        break;
      }
    }
    for (int i = 1; i < parts.length; i++) {
      if (object == noSuchProperty) {
        return noSuchProperty;
      }
      object = _getNamedProperty(object, parts[i]);
    }
    return object;
  }

  // Returns the property of the given object by name. For a map,
  // which contains the key name, this is object[name]. For other
  // objects, this is object.name or object.name(). If no property
  // by the given name exists, this method returns noSuchProperty.
  Object? _getNamedProperty(dynamic object, String name) {
    if (object is Map && object.containsKey(name)) {
      return object[name];
    }

    if (object is List && _integerTag.hasMatch(name)) {
      final int index = int.parse(name);
      if (object.length > index) {
        return object[index];
      }
    }
    return noSuchProperty;
  }

  m.TemplateException error(String message, Node node) =>
      TemplateException(message, templateName, source, node.start);

  static const Map<int, String> _htmlEscapeMap = <int, String>{
    _AMP: '&amp;',
    _LT: '&lt;',
    _GT: '&gt;',
    _QUOTE: '&quot;',
    _APOS: '&#x27;',
    _FORWARD_SLASH: '&#x2F;',
  };

  String _htmlEscape(String s) {
    final StringBuffer buffer = StringBuffer();
    int startIndex = 0;
    int i = 0;
    for (final int c in s.runes) {
      if (c == _AMP ||
          c == _LT ||
          c == _GT ||
          c == _QUOTE ||
          c == _APOS ||
          c == _FORWARD_SLASH) {
        buffer.write(s.substring(startIndex, i));
        buffer.write(_htmlEscapeMap[c]);
        startIndex = i + 1;
      }
      i++;
    }
    buffer.write(s.substring(startIndex));
    return buffer.toString();
  }
}

const int _AMP = 38;
const int _LT = 60;
const int _GT = 62;
const int _QUOTE = 34;
const int _APOS = 39;
const int _FORWARD_SLASH = 47;
const int _NEWLINE = 10;
