/*
 * Copyright 2009 Google Inc.
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package com.google.common.css.compiler.passes;

import com.google.common.annotations.VisibleForTesting;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableSet;
import com.google.common.collect.Lists;
import com.google.common.css.compiler.ast.CssAtRuleNode;
import com.google.common.css.compiler.ast.CssAtRuleNode.Type;
import com.google.common.css.compiler.ast.CssBlockNode;
import com.google.common.css.compiler.ast.CssBooleanExpressionNode;
import com.google.common.css.compiler.ast.CssCompilerPass;
import com.google.common.css.compiler.ast.CssCompositeValueNode;
import com.google.common.css.compiler.ast.CssConditionalBlockNode;
import com.google.common.css.compiler.ast.CssDeclarationBlockNode;
import com.google.common.css.compiler.ast.CssFontFaceNode;
import com.google.common.css.compiler.ast.CssFunctionNode;
import com.google.common.css.compiler.ast.CssImportBlockNode;
import com.google.common.css.compiler.ast.CssImportRuleNode;
import com.google.common.css.compiler.ast.CssLiteralNode;
import com.google.common.css.compiler.ast.CssMediaRuleNode;
import com.google.common.css.compiler.ast.CssNode;
import com.google.common.css.compiler.ast.CssPageRuleNode;
import com.google.common.css.compiler.ast.CssPageSelectorNode;
import com.google.common.css.compiler.ast.CssRootNode;
import com.google.common.css.compiler.ast.CssRulesetNode;
import com.google.common.css.compiler.ast.CssStringNode;
import com.google.common.css.compiler.ast.CssUnknownAtRuleNode;
import com.google.common.css.compiler.ast.CssValueNode;
import com.google.common.css.compiler.ast.ErrorManager;
import com.google.common.css.compiler.ast.GssError;
import com.google.common.css.compiler.ast.MutatingVisitController;

import java.util.List;
import java.util.Set;

/**
 * A compiler pass that transforms standard {@link CssUnknownAtRuleNode} instances
 * to more specific at-rule nodes, or deletes them.
 *
 */
public class CreateStandardAtRuleNodes extends UniformVisitor implements CssCompilerPass {
  @VisibleForTesting
  static final String NO_BLOCK_ERROR_MESSAGE =
    "This @-rule has to have a block";
  @VisibleForTesting
  static final String BLOCK_ERROR_MESSAGE =
    "This @-rule is not allowed to have a block";
  @VisibleForTesting
  static final String ONLY_DECLARATION_BLOCK_ERROR_MESSAGE =
    "Only declaration blocks are allowed for this @-rule";
  @VisibleForTesting
  static final String INVALID_PARAMETERS_ERROR_MESSAGE =
    "This @-rule has invalid parameters";

  @VisibleForTesting
  static final String MEDIA_INVALID_CHILD_ERROR_MESSAGE =
    "This is not valid inside an @media block";
  @VisibleForTesting
  static final String MEDIA_WITHOUT_PARAMETERS_ERROR_MESSAGE =
    "@media without parameters";

  @VisibleForTesting
  static final String PAGE_SELECTOR_PARAMETERS_ERROR_MESSAGE =
    "Page selectors are not allowed to have parameters";

  @VisibleForTesting
  static final String FONT_FACE_PARAMETERS_ERROR_MESSAGE =
    "@font-face is not allowed to have parameters";

  @VisibleForTesting
  static final String IGNORED_IMPORT_WARNING_MESSAGE =
      "@import rules should occur outside blocks and can only be preceded "
      + "by @charset and other @import rules.";

  @VisibleForTesting
  static final String IGNORE_IMPORT_WARNING_MESSAGE =
      "A node after which all @import rule nodes are ignored is here.";

  private static final List<Type> PAGE_SELECTORS =
    ImmutableList.of(Type.TOP_LEFT_CORNER,
        Type.TOP_LEFT, Type.TOP_CENTER, Type.TOP_RIGHT, Type.TOP_RIGHT_CORNER,
        Type.LEFT_TOP, Type.LEFT_MIDDLE, Type.LEFT_BOTTOM, Type.RIGHT_TOP,
        Type.RIGHT_MIDDLE, Type.RIGHT_BOTTOM, Type.BOTTOM_LEFT_CORNER,
        Type.BOTTOM_LEFT, Type.BOTTOM_CENTER, Type.BOTTOM_RIGHT,
        Type.BOTTOM_RIGHT_CORNER);
  private static final Set<String> PSEUDO_PAGES = ImmutableSet.of(
      ":left", ":right", ":first");
  // The @-rules are restricted because it only makes sense for them to be used
  // inside an @media block. Especially @def should never be allowed because it
  // can be misleading; @def rules are processed by the compiler but @media
  // rules are handled by the browser.
  private static final Set<String> ALLOWED_AT_RULES_IN_MEDIA = ImmutableSet.of(
      "page", "if", "elseif", "else", "for");

  private final MutatingVisitController visitController;
  private final ErrorManager errorManager;
  private final List<CssImportRuleNode> nonIgnoredImportRules =
      Lists.newArrayList();
  private CssRootNode root;

  /**
   * The first node after which import rules will be ignored, or null if
   * no such nodes have been discovered.
   */
  private CssNode noMoreImportRules;

  public CreateStandardAtRuleNodes(MutatingVisitController visitController,
                                   ErrorManager errorManager) {
    this.visitController = visitController;
    this.errorManager = errorManager;
  }

  @Override
  public boolean enterTree(CssRootNode root) {
    this.root = root;
    noMoreImportRules = null;
    return true;
  }

  @Override
  public void leave(CssNode node) {
    if (node instanceof CssImportRuleNode
        || node instanceof CssImportBlockNode
        || node == root.getCharsetRule()) {
      return;
    }
    noMoreImportRules = node;
  }

  @Override
  public boolean enterUnknownAtRule(CssUnknownAtRuleNode node) {
    String charsetName = CssAtRuleNode.Type.CHARSET.getCanonicalName();
    String importName = CssAtRuleNode.Type.IMPORT.getCanonicalName();
    String mediaName = CssAtRuleNode.Type.MEDIA.getCanonicalName();
    String pageName = CssAtRuleNode.Type.PAGE.getCanonicalName();
    String fontFaceName = CssAtRuleNode.Type.FONT_FACE.getCanonicalName();

    List<CssValueNode> params = node.getParameters();

    if (node.getName().getValue().equals(charsetName)) {
      // We don't have a specific node class for this, should be handled at the parser level.
      // TODO(user): Give a warning instead that the node has been removed without processing. (?)
      reportError("@" + charsetName + " removed", node);
      return false;

    } else if (node.getName().getValue().equals(importName)) {
      if (params.isEmpty()) {
        reportError("@" + importName + " without a following string or uri", node);
        return false;
      }
      if (params.size() > 2) {
        reportError("@" + importName + " with too many parameters", node);
        return false;
      }
      CssValueNode param = params.get(0);
      if (!((param instanceof CssStringNode) || checkIfUri(param))) {
        reportError("@" + importName + "'s first parameter has to be a string or an url", node);
        return false;
      }
      List<CssValueNode> paramlist = Lists.newArrayList(param);
      if (params.size() == 2) {
        CssValueNode param2 = params.get(1);
        if (param2 instanceof CssCompositeValueNode || param2 instanceof CssLiteralNode) {
          paramlist.add(param2);
        } else {
          reportError("@" + importName + " has illegal parameter", node);
          return false;
        }
      }
      CssImportRuleNode importRule = new CssImportRuleNode(node.getComments());
      importRule.setParameters(paramlist);
      importRule.setSourceCodeLocation(node.getSourceCodeLocation());
      if (noMoreImportRules != null) {
        visitController.replaceCurrentBlockChildWith(
            Lists.newArrayList((CssNode) importRule),
            false /* visitTheReplacementNodes */);
        reportWarning(IGNORED_IMPORT_WARNING_MESSAGE, node);
        reportWarning(IGNORE_IMPORT_WARNING_MESSAGE, noMoreImportRules);
      } else {
        visitController.removeCurrentNode();
        nonIgnoredImportRules.add(importRule);
      }
      return false;

    } else if (node.getName().getValue().equals(mediaName)) {
      createMediaRule(node);
    } else if (node.getName().getValue().equals(pageName)) {
      createPageRule(node);
    } else if (node.getName().getValue().equals(fontFaceName)) {
      createFontFaceRule(node);
    } else {
      for (Type type : PAGE_SELECTORS) {
        if (node.getName().getValue().equals(type.getCanonicalName())) {
          createPageSelector(type, node);
          break;
        }
      }
    }
    return true;
  }

  @Override
  public void leaveTree(CssRootNode root) {
    for (CssImportRuleNode importRule : nonIgnoredImportRules) {
       root.getImportRules().addChildToBack(importRule);
    }
  }

  /**
   * See the
   * <a href="http://www.w3.org/TR/CSS21/grammar.html#grammar">CSS 2.1 syntax
   * </a>, and
   * <a href="http://www.w3.org/TR/css3-mediaqueries/#syntax">CSS 3 syntax</a>
   * for more information.
   */
  private void createMediaRule(CssUnknownAtRuleNode node) {
    if (node.getBlock() == null) {
      reportError(NO_BLOCK_ERROR_MESSAGE, node);
      return;
    }

    if (!(node.getBlock() instanceof CssBlockNode)) {
      reportError(ONLY_DECLARATION_BLOCK_ERROR_MESSAGE, node);
      return;
    }

    CssBlockNode block = (CssBlockNode) node.getBlock();
    for (CssNode part : block.childIterable()) {
      if (!isValidInMediaRule(part)) {
        reportError(MEDIA_INVALID_CHILD_ERROR_MESSAGE, part);
        return;
      }
    }

    List<CssValueNode> params = node.getParameters();
    if (params.isEmpty()) {
      reportError(MEDIA_WITHOUT_PARAMETERS_ERROR_MESSAGE, node);
      return;
    }

    // TODO(fbenz): Perform this check depending on the CSS version set in the
    // options
    if (!checkMediaParameter(params)) {
      reportError(INVALID_PARAMETERS_ERROR_MESSAGE, node);
      return;
    }
    CssMediaRuleNode mediaRule = new CssMediaRuleNode(node.getComments(),
        block);
    mediaRule.setParameters(params);
    mediaRule.setSourceCodeLocation(node.getSourceCodeLocation());
    visitController.replaceCurrentBlockChildWith(
        Lists.newArrayList(mediaRule), true /* visitTheReplacementNodes */);
  }

  /**
   * Checks whether the parameters of a @media rule match the grammar of the
   * CSS 3 draft.
   */
  private boolean checkMediaParameter(List<CssValueNode> params) {
    if (params.get(0) instanceof CssCompositeValueNode) {
      return checkMediaCompositeExpression(params, 0);
    } else if (params.get(0) instanceof CssBooleanExpressionNode) {
      // shorthand syntax: implicit "all and..."
      return checkMediaExpression(params, 0);
    } else if (!(params.get(0) instanceof CssLiteralNode)) {
      // nodes like the function node are invalid
      CssValueNode node = params.get(0);
      reportWarning(
          String.format(
              "Expected CssLiteralNode but found %s",
              node.getClass().getName()),
          node);
      return false;
    }
    int numberOfStartingLiterals = 1;
    String firstValue = params.get(0).getValue();
    if (firstValue.equals("only") || firstValue.equals("not")) {
      numberOfStartingLiterals = 2;
    }
    if (params.size() < numberOfStartingLiterals) {
      // only 'only' or 'not'
      reportWarning(
          "Expected CssLiteralNode after 'only' or 'not'.",
          params.get(params.size() - 1));
      return false;
    }
    if (params.size() - numberOfStartingLiterals > 0) {
      return checkAndMediaExpression(params, numberOfStartingLiterals);
    }
    return true;
  }

  /**
   * Checks for [ AND S* expression ]* where expression is some value.
   */
  private boolean checkAndMediaExpression(
      List<CssValueNode> params, int start) {
    if (params.size() - start < 2) {
      // at least two more: 'and X'
      return false;
    }
    if (!params.get(start).getValue().equals("and")) {
      // expected 'and'
      return false;
    }
    return checkMediaExpression(params, start + 1);
  }

  /**
   * Checks for S* expression | S* expression [ AND S* expression ]*
   * where expression is some value.
   */
  private boolean checkMediaExpression(
      List<CssValueNode> params, int start) {
    if (params.size() - start < 1) {
      // at least one
      return false;
    }
    if (params.get(start) instanceof CssCompositeValueNode) {
      return checkMediaCompositeExpression(params, start);
    } else if (params.size() > start + 1) {
      return checkAndMediaExpression(params, start + 1);
    }
    return true;
  }

  /**
   * Splits up a composite value so that it can be checked. Two values with
   * a comma in between are combined to a composite value.
   * For example:
   * {@code screen and (device-width:800px), print}
   * turns into this list of values:
   * {@code screen}, {@code and}, {@code (device-width:800px), print}
   * where the last value is a composite value that consists of two values.
   */
  private boolean checkMediaCompositeExpression(List<CssValueNode> params,
      int start) {
    CssCompositeValueNode comp = (CssCompositeValueNode) params.get(start);
    CssValueNode startValue;
    if (comp.getValues().size() == 2) {
      startValue = comp.getValues().get(1);
    } else {
      List<CssValueNode> newChildren = Lists.newArrayList(comp.getValues());
      newChildren.remove(0);
      startValue = new CssCompositeValueNode(newChildren,
          comp.getOperator(), comp.getSourceCodeLocation());
    }
    List<CssValueNode> newParams = Lists.newArrayList(startValue);
    for (int i = 0; i < params.size(); i++) {
      if (i > start) {
        newParams.add(params.get(i));
      }
    }
    return checkMediaParameter(newParams);
  }

  private boolean isValidInMediaRule(CssNode node) {
    if (node instanceof CssRulesetNode) {
      // rulesets like .CLASS { ... }
      return true;
    }
    if (node instanceof CssAtRuleNode && ALLOWED_AT_RULES_IN_MEDIA.contains(
        ((CssAtRuleNode) node).getName().getValue())) {
      // like @page or @if, but not @def
      return true;
    }
    if (node instanceof CssConditionalBlockNode) {
      // @if, @elseif, @else after processing because then they are not @-rules
      // anymore
      return true;
    }
    return false;
  }

  /**
   * See the
   * <a href="http://www.w3.org/TR/css3-page/#syntax-page-selector">
   * Page selector grammar</a> for more information.
   */
  private void createPageRule(CssUnknownAtRuleNode node) {
    if (node.getBlock() == null) {
      reportError(NO_BLOCK_ERROR_MESSAGE, node);
      return;
    }
    if (!(node.getBlock() instanceof CssDeclarationBlockNode)) {
      reportError(ONLY_DECLARATION_BLOCK_ERROR_MESSAGE, node);
      return;
    }
    List<CssValueNode> params = node.getParameters();
    int numParams = params.size();
    if (numParams > 2) {
      reportError(INVALID_PARAMETERS_ERROR_MESSAGE, node);
      return;
    } else if (numParams == 2
          && !PSEUDO_PAGES.contains(params.get(1).getValue())) {
      reportError(INVALID_PARAMETERS_ERROR_MESSAGE, node);
      return;
    } else if (numParams == 1) {
      if (params.get(0).getValue().startsWith(":")
          && !PSEUDO_PAGES.contains(params.get(0).getValue())) {
        reportError(INVALID_PARAMETERS_ERROR_MESSAGE, node);
        return;
      }
    }
    CssDeclarationBlockNode block = (CssDeclarationBlockNode) node.getBlock();
    CssPageRuleNode pageRule = new CssPageRuleNode(node.getComments(),
        block);
    pageRule.setParameters(params);
    pageRule.setSourceCodeLocation(node.getSourceCodeLocation());
    visitController.replaceCurrentBlockChildWith(
        Lists.newArrayList(pageRule), true /* visitTheReplacementNodes */);
  }

  private void createPageSelector(Type type, CssUnknownAtRuleNode node) {
    if (node.getBlock() == null) {
      reportError(NO_BLOCK_ERROR_MESSAGE, node);
      return;
    }
    if (!(node.getBlock() instanceof CssDeclarationBlockNode)) {
      reportError(ONLY_DECLARATION_BLOCK_ERROR_MESSAGE, node);
      return;
    }
    if (!node.getParameters().isEmpty()) {
      reportError(PAGE_SELECTOR_PARAMETERS_ERROR_MESSAGE, node);
      return;
    }
    CssDeclarationBlockNode block = (CssDeclarationBlockNode) node.getBlock();
    CssPageSelectorNode pageSelector = new CssPageSelectorNode(type,
        node.getComments(), block);
    pageSelector.setSourceCodeLocation(node.getSourceCodeLocation());
    visitController.replaceCurrentBlockChildWith(
        Lists.newArrayList(pageSelector), true /* visitTheReplacementNodes */);
  }

  /**
   * See the
   * <a href="http://www.w3.org/TR/css3-fonts/#font-face-rule">
   * font-face grammar</a> for more information.
   */
  private void createFontFaceRule(CssUnknownAtRuleNode node) {
    if (node.getBlock() == null) {
      reportError(NO_BLOCK_ERROR_MESSAGE, node);
      return;
    }
    if (!(node.getBlock() instanceof CssDeclarationBlockNode)) {
      reportError(ONLY_DECLARATION_BLOCK_ERROR_MESSAGE, node);
      return;
    }
    if (!node.getParameters().isEmpty()) {
      reportError(FONT_FACE_PARAMETERS_ERROR_MESSAGE, node);
      return;
    }

    // TODO(bolinfest): Verify that all declarations in the block are valid
    // font-descriptions: font-family, src, font-style, etc. See
    // http://www.w3.org/TR/css3-fonts/#font-resources for a complete list.

    CssDeclarationBlockNode block = (CssDeclarationBlockNode) node.getBlock();
    CssFontFaceNode fontFace = new CssFontFaceNode(node.getComments(), block);
    fontFace.setSourceCodeLocation(node.getSourceCodeLocation());
    visitController.replaceCurrentBlockChildWith(
        Lists.newArrayList(fontFace), true /* visitTheReplacementNodes */);
  }

  private boolean checkIfUri(CssValueNode node) {
    if (!(node instanceof CssFunctionNode)) {
      return false;
    }
    CssFunctionNode function = (CssFunctionNode) node;
    if (function.getFunctionName().toLowerCase().equals("url")) {
      return true;
    }
    return false;
  }

  private void reportError(String message, CssNode node) {
    errorManager.report(new GssError(message, node.getSourceCodeLocation()));
    visitController.removeCurrentNode();
  }

  private void reportWarning(String message, CssNode node) {
    errorManager.reportWarning(
        new GssError(message, node.getSourceCodeLocation()));
  }

  @Override
  public void runPass() {
    visitController.startVisit(this);
  }

}
