/*
 * Licensed to the Apache Software Foundation (ASF) under one
 * or more contributor license agreements.  See the NOTICE file
 * distributed with this work for additional information
 * regarding copyright ownership.  The ASF licenses this file
 * to you 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
 *
 *   https://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 org.apache.accumulo.core.security;

import java.util.ArrayList;

import org.apache.accumulo.core.data.ArrayByteSequence;
import org.apache.accumulo.core.data.ByteSequence;
import org.apache.accumulo.core.security.ColumnVisibility.Node;

/**
 * A class which evaluates visibility expressions against a set of authorizations.
 */
public class VisibilityEvaluator {
  private AuthorizationContainer auths;

  /**
   * Authorizations in column visibility expression are in escaped form. Column visibility parsing
   * does not unescape. This class wraps an AuthorizationContainer and unescapes auths before
   * checking the wrapped container.
   */
  private static class UnescapingAuthorizationContainer implements AuthorizationContainer {

    private AuthorizationContainer wrapped;

    UnescapingAuthorizationContainer(AuthorizationContainer wrapee) {
      this.wrapped = wrapee;
    }

    @Override
    public boolean contains(ByteSequence auth) {
      return wrapped.contains(unescape(auth));
    }
  }

  static ByteSequence unescape(ByteSequence auth) {
    int escapeCharCount = 0;
    for (int i = 0; i < auth.length(); i++) {
      byte b = auth.byteAt(i);
      if (b == '"' || b == '\\') {
        escapeCharCount++;
      }
    }

    if (escapeCharCount > 0) {
      if (escapeCharCount % 2 == 1) {
        throw new IllegalArgumentException("Illegal escape sequence in auth : " + auth);
      }

      byte[] unescapedCopy = new byte[auth.length() - escapeCharCount / 2];
      int pos = 0;
      for (int i = 0; i < auth.length(); i++) {
        byte b = auth.byteAt(i);
        if (b == '\\') {
          i++;
          b = auth.byteAt(i);
          if (b != '"' && b != '\\') {
            throw new IllegalArgumentException("Illegal escape sequence in auth : " + auth);
          }
        } else if (b == '"') {
          // should only see quote after a slash
          throw new IllegalArgumentException("Illegal escape sequence in auth : " + auth);
        }

        unescapedCopy[pos++] = b;
      }

      return new ArrayByteSequence(unescapedCopy);
    } else {
      return auth;
    }
  }

  /**
   * Creates a new {@link Authorizations} object with escaped forms of the authorizations in the
   * given object.
   *
   * @param auths original authorizations
   * @return authorizations object with escaped authorization strings
   * @see #escape(byte[], boolean)
   */
  static Authorizations escape(Authorizations auths) {
    ArrayList<byte[]> retAuths = new ArrayList<>(auths.getAuthorizations().size());

    for (byte[] auth : auths.getAuthorizations()) {
      retAuths.add(escape(auth, false));
    }

    return new Authorizations(retAuths);
  }

  /**
   * Properly escapes an authorization string. The string can be quoted if desired.
   *
   * @param auth authorization string, as UTF-8 encoded bytes
   * @param quote true to wrap escaped authorization in quotes
   * @return escaped authorization string
   */
  public static byte[] escape(byte[] auth, boolean quote) {
    int escapeCount = 0;

    for (byte value : auth) {
      if (value == '"' || value == '\\') {
        escapeCount++;
      }
    }

    if (escapeCount > 0 || quote) {
      byte[] escapedAuth = new byte[auth.length + escapeCount + (quote ? 2 : 0)];
      int index = quote ? 1 : 0;
      for (byte b : auth) {
        if (b == '"' || b == '\\') {
          escapedAuth[index++] = '\\';
        }
        escapedAuth[index++] = b;
      }

      if (quote) {
        escapedAuth[0] = '"';
        escapedAuth[escapedAuth.length - 1] = '"';
      }

      auth = escapedAuth;
    }
    return auth;
  }

  /**
   * Creates a new evaluator for the authorizations found in the given container.
   *
   * @since 1.7.0
   */
  public VisibilityEvaluator(AuthorizationContainer authsContainer) {
    this.auths = new UnescapingAuthorizationContainer(authsContainer);
  }

  /**
   * Creates a new evaluator for the given collection of authorizations. Each authorization string
   * is escaped before handling, and the original strings are unchanged.
   *
   * @param authorizations authorizations object
   */
  public VisibilityEvaluator(Authorizations authorizations) {
    this.auths = escape(authorizations);
  }

  /**
   * Evaluates the given column visibility against the authorizations provided to this evaluator. A
   * visibility passes evaluation if all authorizations in it are contained in those known to the
   * evaluator, and all AND and OR subexpressions have at least two children.
   *
   * @param visibility column visibility to evaluate
   * @return true if visibility passes evaluation
   * @throws VisibilityParseException if an AND or OR subexpression has less than two children, or a
   *         subexpression is of an unknown type
   */
  public boolean evaluate(ColumnVisibility visibility) throws VisibilityParseException {
    // The VisibilityEvaluator computes a trie from the given Authorizations, that ColumnVisibility
    // expressions can be evaluated against.
    return evaluate(visibility.getExpression(), visibility.getParseTree());
  }

  private final boolean evaluate(final byte[] expression, final Node root)
      throws VisibilityParseException {
    if (expression.length == 0) {
      return true;
    }
    switch (root.type) {
      case TERM:
        return auths.contains(root.getTerm(expression));
      case AND:
        if (root.children == null || root.children.size() < 2) {
          throw new VisibilityParseException("AND has less than 2 children", expression,
              root.start);
        }
        for (Node child : root.children) {
          if (!evaluate(expression, child)) {
            return false;
          }
        }
        return true;
      case OR:
        if (root.children == null || root.children.size() < 2) {
          throw new VisibilityParseException("OR has less than 2 children", expression, root.start);
        }
        for (Node child : root.children) {
          if (evaluate(expression, child)) {
            return true;
          }
        }
        return false;
      default:
        throw new VisibilityParseException("No such node type", expression, root.start);
    }
  }
}
