// Copyright (c) 2013, the Dart project authors.  Please see the AUTHORS file
// for details. All rights reserved. Use of this source code is governed by a
// BSD-style license that can be found in the LICENSE file.

part of '../visitor.dart';

/// Visitor that produces a formatted string representation of the CSS tree.
class CssPrinter extends Visitor {
  StringBuffer _buff = StringBuffer();
  bool _prettyPrint = true;
  bool _isInKeyframes = false;
  int _indent = 0;
  bool _startOfLine = true;

  /// Walk the [tree] Stylesheet. [pretty] if true emits line breaks, extra
  /// spaces, friendly property values, etc., if false emits compacted output.
  @override
  void visitTree(StyleSheet tree, {bool pretty = false}) {
    _prettyPrint = pretty;
    _buff = StringBuffer();
    _indent = 0;
    _startOfLine = true;

    visitStyleSheet(tree);
  }

  /// Appends [str] to the output buffer.
  void emit(String str) {
    if (_prettyPrint) {
      if (_startOfLine) {
        _startOfLine = false;
        _buff.write(' ' * _indent);
      }
      _buff.write(str);
    } else {
      _buff.write(str);
    }
  }

  void _emitLBrace() {
    _indent += 2;
    _buff.write('{');

    if (_prettyPrint) {
      _buff.writeln();
      _startOfLine = true;
    }
  }

  void _emitRBrace() {
    _indent -= 2;

    if (_prettyPrint) {
      if (!_startOfLine) _buff.write('\n');
      _buff.write('${' ' * _indent}}\n');
      _startOfLine = true;
    } else {
      _buff.write('}');
    }
  }

  void _emitSemicolon({bool forceLf = false}) {
    if (_prettyPrint) {
      _buff.write(';\n');
      _startOfLine = true;
    } else {
      _buff.write(';');
      if (forceLf) _buff.write('\n');
    }
  }

  void _emitLf({bool force = false}) {
    if (_prettyPrint) {
      _buff.write('\n');
      _startOfLine = true;
    } else if (force) {
      _buff.write('\n');
    }
  }

  /// Returns the output buffer.
  @override
  String toString() => _buff.toString().trim();

  String get _sp => _prettyPrint ? ' ' : '';

  // TODO(terry): When adding obfuscation we'll need isOptimized (compact w/
  //              obfuscation) and have isTesting (compact no obfuscation) and
  //              isCompact would be !prettyPrint.  We'll need another boolean
  //              flag for obfuscation.
  bool get _isTesting => !_prettyPrint;

  @override
  void visitCalcTerm(CalcTerm node) {
    emit('${node.text}(');
    node.expr.visit(this);
    emit(')');
  }

  @override
  void visitCssComment(CssComment node) {
    emit('/* ${node.comment} */');
  }

  @override
  void visitCommentDefinition(CommentDefinition node) {
    emit('<!-- ${node.comment} -->');
  }

  @override
  void visitMediaExpression(MediaExpression node) {
    emit(node.andOperator ? ' AND ' : ' ');
    emit('(${node.mediaFeature}');
    if (node.exprs.expressions.isNotEmpty) {
      emit(':');
      visitExpressions(node.exprs);
    }
    emit(')');
  }

  @override
  void visitMediaQuery(MediaQuery node) {
    var unary = node.hasUnary ? ' ${node.unary}' : '';
    var mediaType = node.hasMediaType ? ' ${node.mediaType}' : '';
    emit('$unary$mediaType');
    for (var expression in node.expressions) {
      visitMediaExpression(expression);
    }
  }

  void emitMediaQueries(List<MediaQuery> queries) {
    var queriesLen = queries.length;
    for (var i = 0; i < queriesLen; i++) {
      var query = queries[i];
      if (i > 0) emit(',');
      visitMediaQuery(query);
    }
  }

  @override
  void visitDocumentDirective(DocumentDirective node) {
    emit('@-moz-document ');
    node.functions.first.visit(this);
    for (var function in node.functions.skip(1)) {
      emit(',$_sp');
      function.visit(this);
    }
    emit(_sp);
    _emitLBrace();
    for (var ruleSet in node.groupRuleBody) {
      ruleSet.visit(this);
    }
    _emitRBrace();
  }

  @override
  void visitSupportsDirective(SupportsDirective node) {
    emit('@supports ');
    node.condition!.visit(this);
    emit(_sp);
    _emitLBrace();
    for (var rule in node.groupRuleBody) {
      rule.visit(this);
    }
    _emitRBrace();
  }

  @override
  void visitSupportsConditionInParens(SupportsConditionInParens node) {
    emit('(');
    node.condition!.visit(this);
    emit(')');
  }

  @override
  void visitSupportsNegation(SupportsNegation node) {
    emit('not$_sp');
    node.condition.visit(this);
  }

  @override
  void visitSupportsConjunction(SupportsConjunction node) {
    node.conditions.first.visit(this);
    for (var condition in node.conditions.skip(1)) {
      emit('${_sp}and$_sp');
      condition.visit(this);
    }
  }

  @override
  void visitSupportsDisjunction(SupportsDisjunction node) {
    node.conditions.first.visit(this);
    for (var condition in node.conditions.skip(1)) {
      emit('${_sp}or$_sp');
      condition.visit(this);
    }
  }

  @override
  void visitViewportDirective(ViewportDirective node) {
    emit('@${node.name}$_sp');
    _emitLBrace();
    node.declarations.visit(this);
    _emitRBrace();
  }

  @override
  void visitMediaDirective(MediaDirective node) {
    emit('@media');
    emitMediaQueries(node.mediaQueries.cast<MediaQuery>());
    emit(_sp);
    _emitLBrace();
    for (var ruleset in node.rules) {
      ruleset.visit(this);
    }
    _emitRBrace();
  }

  @override
  void visitHostDirective(HostDirective node) {
    emit('@host$_sp');
    _emitLBrace();
    for (var ruleset in node.rules) {
      ruleset.visit(this);
    }
    _emitRBrace();
  }

  ///  @page : pseudoPage {
  ///    decls
  ///  }
  @override
  void visitPageDirective(PageDirective node) {
    emit('@page');
    if (node.hasIdent || node.hasPseudoPage) {
      if (node.hasIdent) emit(' ');
      emit(node._ident!);
      emit(node.hasPseudoPage ? ':${node._pseudoPage}' : '');
    }

    var declsMargin = node._declsMargin;
    var declsMarginLength = declsMargin.length;
    emit(_sp);
    _emitLBrace();
    for (var i = 0; i < declsMarginLength; i++) {
      declsMargin[i].visit(this);
    }
    _emitRBrace();
  }

  /// @charset "charset encoding"
  @override
  void visitCharsetDirective(CharsetDirective node) {
    emit('@charset "${node.charEncoding}"');
    _emitSemicolon(forceLf: true);
  }

  @override
  void visitImportDirective(ImportDirective node) {
    bool isStartingQuote(String ch) => '\'"'.contains(ch[0]);

    if (_isTesting) {
      // Emit assuming url() was parsed; most suite tests use url function.
      emit('@import url(${node.import})');
    } else if (isStartingQuote(node.import)) {
      emit('@import ${node.import}');
    } else {
      // url(...) isn't needed only a URI can follow an @import directive; emit
      // url as a string.
      emit('@import "${node.import}"');
    }
    emitMediaQueries(node.mediaQueries);
    _emitSemicolon(forceLf: true);
  }

  @override
  void visitKeyFrameDirective(KeyFrameDirective node) {
    emit('${node.keyFrameName} ');
    node.name!.visit(this);
    emit(_sp);
    _emitLBrace();
    _isInKeyframes = true;
    for (final block in node._blocks) {
      block.visit(this);
    }
    _isInKeyframes = false;
    _emitRBrace();
  }

  @override
  void visitFontFaceDirective(FontFaceDirective node) {
    emit('@font-face');
    emit(_sp);
    _emitLBrace();
    node._declarations.visit(this);
    _emitRBrace();
  }

  @override
  void visitKeyFrameBlock(KeyFrameBlock node) {
    node._blockSelectors.visit(this);
    emit(_sp);
    _emitLBrace();
    node._declarations.visit(this);
    _emitRBrace();
  }

  @override
  void visitStyletDirective(StyletDirective node) {
    emit('/* @stylet export as ${node.dartClassName} */');
    _emitLf(force: true);
  }

  @override
  void visitNamespaceDirective(NamespaceDirective node) {
    bool isStartingQuote(String ch) => '\'"'.contains(ch);

    if (isStartingQuote(node._uri!)) {
      emit('@namespace ${node.prefix}"${node._uri}"');
    } else {
      if (_isTesting) {
        // Emit exactly was we parsed.
        emit('@namespace ${node.prefix}url(${node._uri})');
      } else {
        // url(...) isn't needed only a URI can follow a:
        //    @namespace prefix directive.
        emit('@namespace ${node.prefix}${node._uri}');
      }
    }
    _emitSemicolon(forceLf: true);
  }

  @override
  void visitVarDefinitionDirective(VarDefinitionDirective node) {
    visitVarDefinition(node.def);
    _emitSemicolon();
  }

  @override
  void visitMixinRulesetDirective(MixinRulesetDirective node) {
    emit('@mixin ${node.name} ');
    _emitLBrace();
    for (var ruleset in node.rulesets) {
      ruleset.visit(this);
    }
    _emitRBrace();
  }

  @override
  void visitMixinDeclarationDirective(MixinDeclarationDirective node) {
    emit('@mixin ${node.name}$_sp');
    _emitLBrace();
    visitDeclarationGroup(node.declarations);
    _emitRBrace();
  }

  /// Added optional newLine for handling @include at top-level vs/ inside of
  /// a declaration group.
  @override
  void visitIncludeDirective(IncludeDirective node, [bool topLevel = true]) {
    if (topLevel) _emitLf();
    emit('@include ${node.name}');
    _emitSemicolon(forceLf: true);
  }

  @override
  void visitContentDirective(ContentDirective node) {
    // TODO(terry): TBD
  }

  @override
  void visitRuleSet(RuleSet node) {
    node.selectorGroup!.visit(this);
    emit(_sp);
    _emitLBrace();
    node.declarationGroup.visit(this);
    _emitRBrace();
  }

  @override
  void visitDeclarationGroup(DeclarationGroup node) {
    var declarations = node.declarations;
    var declarationsLength = declarations.length;
    for (var i = 0; i < declarationsLength; i++) {
      declarations[i].visit(this);
      // Don't emit the last semicolon in compact mode.
      if (_prettyPrint || i < declarationsLength - 1) {
        _emitSemicolon();
      }
    }
  }

  @override
  void visitMarginGroup(MarginGroup node) {
    var marginSymName =
        TokenKind.idToValue(TokenKind.MARGIN_DIRECTIVES, node.margin_sym);

    emit('@$marginSymName$_sp');
    _emitLBrace();
    visitDeclarationGroup(node);
    _emitRBrace();
  }

  @override
  void visitDeclaration(Declaration node) {
    emit('${node.property}:$_sp');
    node.expression!.visit(this);
    if (node.important) {
      emit('$_sp!important');
    }
  }

  @override
  void visitVarDefinition(VarDefinition node) {
    emit('var-${node.definedName}: ');
    node.expression!.visit(this);
  }

  @override
  void visitIncludeMixinAtDeclaration(IncludeMixinAtDeclaration node) {
    // Don't emit a new line we're inside of a declaration group.
    visitIncludeDirective(node.include, false);
  }

  @override
  void visitExtendDeclaration(ExtendDeclaration node) {
    emit('@extend ');
    for (var selector in node.selectors) {
      selector.visit(this);
    }
  }

  @override
  void visitSelectorGroup(SelectorGroup node) {
    var selectors = node.selectors;
    var selectorsLength = selectors.length;
    for (var i = 0; i < selectorsLength; i++) {
      if (i > 0) emit(',$_sp');
      selectors[i].visit(this);
    }
  }

  @override
  void visitSimpleSelectorSequence(SimpleSelectorSequence node) {
    emit(node._combinatorToString);
    node.simpleSelector.visit(this);
  }

  @override
  void visitSimpleSelector(SimpleSelector node) {
    emit(node.name);
  }

  @override
  void visitNamespaceSelector(NamespaceSelector node) {
    emit(node.toString());
  }

  @override
  void visitElementSelector(ElementSelector node) {
    emit(node.toString());
  }

  @override
  void visitAttributeSelector(AttributeSelector node) {
    emit(node.toString());
  }

  @override
  void visitIdSelector(IdSelector node) {
    emit(node.toString());
  }

  @override
  void visitClassSelector(ClassSelector node) {
    emit(node.toString());
  }

  @override
  void visitPseudoClassSelector(PseudoClassSelector node) {
    emit(node.toString());
  }

  @override
  void visitPseudoElementSelector(PseudoElementSelector node) {
    emit(node.toString());
  }

  @override
  void visitPseudoClassFunctionSelector(PseudoClassFunctionSelector node) {
    emit(':${node.name}(');
    node.argument.visit(this);
    emit(')');
  }

  @override
  void visitPseudoElementFunctionSelector(PseudoElementFunctionSelector node) {
    emit('::${node.name}(');
    node.expression.visit(this);
    emit(')');
  }

  @override
  void visitNegationSelector(NegationSelector node) {
    emit(':not(');
    node.negationArg!.visit(this);
    emit(')');
  }

  @override
  void visitSelectorExpression(SelectorExpression node) {
    var expressions = node.expressions;
    var expressionsLength = expressions.length;
    for (var i = 0; i < expressionsLength; i++) {
      // Add space seperator between terms without an operator.
      var expression = expressions[i];
      expression.visit(this);
    }
  }

  @override
  void visitUnicodeRangeTerm(UnicodeRangeTerm node) {
    if (node.hasSecond) {
      emit('U+${node.first}-${node.second}');
    } else {
      emit('U+${node.first}');
    }
  }

  @override
  void visitLiteralTerm(LiteralTerm node) {
    emit(node.text);
  }

  @override
  void visitHexColorTerm(HexColorTerm node) {
    String? mappedName;
    if (_isTesting && (node.value is! BAD_HEX_VALUE)) {
      mappedName = TokenKind.hexToColorName(node.value);
    }
    mappedName ??= '#${node.text}';

    emit(mappedName);
  }

  @override
  void visitNumberTerm(NumberTerm node) {
    visitLiteralTerm(node);
  }

  @override
  void visitUnitTerm(UnitTerm node) {
    emit(node.toString());
  }

  @override
  void visitLengthTerm(LengthTerm node) {
    emit(node.toString());
  }

  @override
  void visitPercentageTerm(PercentageTerm node) {
    emit('${node.text}%');
  }

  @override
  void visitEmTerm(EmTerm node) {
    emit('${node.text}em');
  }

  @override
  void visitExTerm(ExTerm node) {
    emit('${node.text}ex');
  }

  @override
  void visitAngleTerm(AngleTerm node) {
    emit(node.toString());
  }

  @override
  void visitTimeTerm(TimeTerm node) {
    emit(node.toString());
  }

  @override
  void visitFreqTerm(FreqTerm node) {
    emit(node.toString());
  }

  @override
  void visitFractionTerm(FractionTerm node) {
    emit('${node.text}fr');
  }

  @override
  void visitUriTerm(UriTerm node) {
    emit('url("${node.text}")');
  }

  @override
  void visitResolutionTerm(ResolutionTerm node) {
    emit(node.toString());
  }

  @override
  void visitViewportTerm(ViewportTerm node) {
    emit(node.toString());
  }

  @override
  void visitLineHeightTerm(LineHeightTerm node) {
    emit(node.toString());
  }

  @override
  void visitFunctionTerm(FunctionTerm node) {
    // TODO(terry): Optimize rgb to a hexcolor.
    emit('${node.text}(');
    node._params.visit(this);
    emit(')');
  }

  @override
  void visitGroupTerm(GroupTerm node) {
    emit('(');
    var terms = node._terms;
    var termsLength = terms.length;
    for (var i = 0; i < termsLength; i++) {
      if (i > 0) emit(_sp);
      terms[i].visit(this);
    }
    emit(')');
  }

  @override
  void visitItemTerm(ItemTerm node) {
    emit('[${node.text}]');
  }

  @override
  void visitIE8Term(IE8Term node) {
    visitLiteralTerm(node);
  }

  @override
  void visitOperatorSlash(OperatorSlash node) {
    emit('/');
  }

  @override
  void visitOperatorComma(OperatorComma node) {
    emit(',');
  }

  @override
  void visitOperatorPlus(OperatorPlus node) {
    emit('+');
  }

  @override
  void visitOperatorMinus(OperatorMinus node) {
    emit('-');
  }

  @override
  void visitVarUsage(VarUsage node) {
    emit('var(${node.name}');
    if (node.defaultValues.isNotEmpty) {
      emit(',');
      for (var defaultValue in node.defaultValues) {
        emit(' ');
        defaultValue.visit(this);
      }
    }
    emit(')');
  }

  @override
  void visitExpressions(Expressions node) {
    var expressions = node.expressions;
    var expressionsLength = expressions.length;

    for (var i = 0; i < expressionsLength; i++) {
      // Add space seperator between terms without an operator.
      // TODO(terry): Should have a BinaryExpression to solve this problem.
      var expression = expressions[i];
      if (i > 0 &&
          !(expression is OperatorComma || expression is OperatorSlash)) {
        // If the previous expression is an operator, use `_sp` so the space is
        // collapsed when emitted in compact mode. If the previous expression
        // isn't an operator, the space is significant to delimit the two
        // expressions and can't be collapsed.
        var previous = expressions[i - 1];
        if (previous is OperatorComma || previous is OperatorSlash) {
          emit(_sp);
        } else if (previous is PercentageTerm &&
            expression is PercentageTerm &&
            _isInKeyframes) {
          emit(',');
          emit(_sp);
        } else {
          emit(' ');
        }
      }
      expression.visit(this);
    }
  }

  @override
  void visitBinaryExpression(BinaryExpression node) {
    // TODO(terry): TBD
    throw UnimplementedError('visitBinaryExpression');
  }

  @override
  void visitUnaryExpression(UnaryExpression node) {
    // TODO(terry): TBD
    throw UnimplementedError('visitUnaryExpression');
  }

  @override
  void visitIdentifier(Identifier node) {
    emit(node.name);
  }

  @override
  void visitWildcard(Wildcard node) {
    emit('*');
  }

  @override
  void visitDartStyleExpression(DartStyleExpression node) {
    // TODO(terry): TBD
    throw UnimplementedError('visitDartStyleExpression');
  }
}
