/*
 * 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
 *
 *     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.datastax.oss.driver.internal.core.metadata.schema.parsing;

import com.datastax.oss.driver.api.core.CqlIdentifier;
import com.datastax.oss.driver.api.core.DefaultProtocolVersion;
import com.datastax.oss.driver.api.core.detach.AttachmentPoint;
import com.datastax.oss.driver.api.core.type.DataType;
import com.datastax.oss.driver.api.core.type.DataTypes;
import com.datastax.oss.driver.api.core.type.UserDefinedType;
import com.datastax.oss.driver.api.core.type.codec.TypeCodecs;
import com.datastax.oss.driver.internal.core.context.InternalDriverContext;
import com.datastax.oss.driver.internal.core.type.DefaultTupleType;
import com.datastax.oss.driver.internal.core.type.UserDefinedTypeBuilder;
import com.datastax.oss.driver.internal.core.type.codec.ParseUtils;
import com.datastax.oss.driver.shaded.guava.common.annotations.VisibleForTesting;
import com.datastax.oss.driver.shaded.guava.common.collect.ImmutableList;
import com.datastax.oss.driver.shaded.guava.common.collect.ImmutableMap;
import com.datastax.oss.protocol.internal.util.Bytes;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Iterator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import net.jcip.annotations.ThreadSafe;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

/**
 * Parses data types from schema tables, for Cassandra 2.2 and below.
 *
 * <p>In these versions, data types appear as class names, like
 * "org.apache.cassandra.db.marshal.AsciiType" or
 * "org.apache.cassandra.db.marshal.TupleType(org.apache.cassandra.db.marshal.Int32Type,org.apache.cassandra.db.marshal.Int32Type)".
 *
 * <p>This is modified (and simplified) from Cassandra's {@code TypeParser} class to suit our needs.
 * In particular it's not very efficient, but it doesn't really matter since it's rarely used and
 * never in a critical path.
 */
@ThreadSafe
public class DataTypeClassNameParser implements DataTypeParser {

  private static final Logger LOG = LoggerFactory.getLogger(DataTypeClassNameParser.class);

  @Override
  public DataType parse(
      CqlIdentifier keyspaceId,
      String toParse,
      Map<CqlIdentifier, UserDefinedType> userTypes,
      InternalDriverContext context) {
    // We take keyspaceId as a parameter because of the parent interface, but it's actually unused
    // by this implementation.
    return parse(toParse, userTypes, context, context.getSessionName());
  }

  /**
   * Simplified parse method for external use.
   *
   * <p>This is intended for use in Cassandra's UDF implementation (the current version uses the
   * similar method from driver 3).
   */
  public DataType parse(String toParse, AttachmentPoint attachmentPoint) {
    return parse(
        toParse,
        null, // No caching of user types: nested types will always be fully re-parsed
        attachmentPoint,
        "parser");
  }

  private DataType parse(
      String toParse,
      Map<CqlIdentifier, UserDefinedType> userTypes,
      AttachmentPoint attachmentPoint,
      String logPrefix) {
    boolean frozen = false;
    if (isReversed(toParse)) {
      // Just skip the ReversedType part, we don't care
      toParse = getNestedClassName(toParse);
    } else if (toParse.startsWith("org.apache.cassandra.db.marshal.FrozenType")) {
      frozen = true;
      toParse = getNestedClassName(toParse);
    }

    Parser parser = new Parser(toParse, 0);
    String next = parser.parseNextName();

    if (next.startsWith("org.apache.cassandra.db.marshal.ListType")) {
      DataType elementType =
          parse(parser.getTypeParameters().get(0), userTypes, attachmentPoint, logPrefix);
      return DataTypes.listOf(elementType, frozen);
    }

    if (next.startsWith("org.apache.cassandra.db.marshal.SetType")) {
      DataType elementType =
          parse(parser.getTypeParameters().get(0), userTypes, attachmentPoint, logPrefix);
      return DataTypes.setOf(elementType, frozen);
    }

    if (next.startsWith("org.apache.cassandra.db.marshal.MapType")) {
      List<String> parameters = parser.getTypeParameters();
      DataType keyType = parse(parameters.get(0), userTypes, attachmentPoint, logPrefix);
      DataType valueType = parse(parameters.get(1), userTypes, attachmentPoint, logPrefix);
      return DataTypes.mapOf(keyType, valueType, frozen);
    }

    if (frozen)
      LOG.warn(
          "[{}] Got o.a.c.db.marshal.FrozenType for something else than a collection, "
              + "this driver version might be too old for your version of Cassandra",
          logPrefix);

    if (next.startsWith("org.apache.cassandra.db.marshal.UserType")) {
      ++parser.idx; // skipping '('

      CqlIdentifier keyspace = CqlIdentifier.fromInternal(parser.readOne());
      parser.skipBlankAndComma();
      String typeName =
          TypeCodecs.TEXT.decode(
              Bytes.fromHexString("0x" + parser.readOne()), attachmentPoint.getProtocolVersion());
      if (typeName == null) {
        throw new AssertionError("Type name cannot be null, this is a server bug");
      }
      CqlIdentifier typeId = CqlIdentifier.fromInternal(typeName);
      Map<String, String> nameAndTypeParameters = parser.getNameAndTypeParameters();

      // Avoid re-parsing if we already have the definition
      if (userTypes != null && userTypes.containsKey(typeId)) {
        // copy as frozen since C* 2.x UDTs are always frozen.
        return userTypes.get(typeId).copy(true);
      } else {
        UserDefinedTypeBuilder builder = new UserDefinedTypeBuilder(keyspace, typeId);
        parser.skipBlankAndComma();
        for (Map.Entry<String, String> entry : nameAndTypeParameters.entrySet()) {
          CqlIdentifier fieldName = CqlIdentifier.fromInternal(entry.getKey());
          DataType fieldType = parse(entry.getValue(), userTypes, attachmentPoint, logPrefix);
          builder.withField(fieldName, fieldType);
        }
        // Create a frozen UserType since C* 2.x UDTs are always frozen.
        return builder.frozen().withAttachmentPoint(attachmentPoint).build();
      }
    }

    if (next.startsWith("org.apache.cassandra.db.marshal.TupleType")) {
      List<String> rawTypes = parser.getTypeParameters();
      ImmutableList.Builder<DataType> componentTypesBuilder = ImmutableList.builder();
      for (String rawType : rawTypes) {
        componentTypesBuilder.add(parse(rawType, userTypes, attachmentPoint, logPrefix));
      }
      return new DefaultTupleType(componentTypesBuilder.build(), attachmentPoint);
    }

    if (next.startsWith("org.apache.cassandra.db.marshal.VectorType")) {
      Iterator<String> rawTypes = parser.getTypeParameters().iterator();
      DataType subtype = parse(rawTypes.next(), userTypes, attachmentPoint, logPrefix);
      int dimensions = Integer.parseInt(rawTypes.next());
      return DataTypes.vectorOf(subtype, dimensions);
    }

    DataType type = NATIVE_TYPES_BY_CLASS_NAME.get(next);
    return type == null ? DataTypes.custom(toParse) : type;
  }

  static boolean isReversed(String toParse) {
    return toParse.startsWith("org.apache.cassandra.db.marshal.ReversedType");
  }

  private static String getNestedClassName(String className) {
    Parser p = new Parser(className, 0);
    p.parseNextName();
    List<String> l = p.getTypeParameters();
    if (l.size() != 1) {
      throw new IllegalStateException();
    }
    className = l.get(0);
    return className;
  }

  static class Parser {

    private final String str;
    private int idx;

    Parser(String str, int idx) {
      this.str = str;
      this.idx = idx;
    }

    String parseNextName() {
      skipBlank();
      return readNextIdentifier();
    }

    private String readOne() {
      String name = parseNextName();
      String args = readRawArguments();
      return name + args;
    }

    // Assumes we have just read a class name and read it's potential arguments
    // blindly. I.e. it assume that either parsing is done or that we're on a '('
    // and this reads everything up until the corresponding closing ')'. It
    // returns everything read, including the enclosing parenthesis.
    private String readRawArguments() {
      skipBlank();

      if (isEOS() || str.charAt(idx) == ')' || str.charAt(idx) == ',') {
        return "";
      }

      if (str.charAt(idx) != '(') {
        throw new IllegalStateException(
            String.format(
                "Expecting char %d of %s to be '(' but '%c' found", idx, str, str.charAt(idx)));
      }

      int i = idx;
      int open = 1;
      while (open > 0) {
        ++idx;

        if (isEOS()) {
          throw new IllegalStateException("Non closed parenthesis");
        }

        if (str.charAt(idx) == '(') {
          open++;
        } else if (str.charAt(idx) == ')') {
          open--;
        }
      }
      // we've stopped at the last closing ')' so move past that
      ++idx;
      return str.substring(i, idx);
    }

    List<String> getTypeParameters() {
      List<String> list = new ArrayList<>();

      if (isEOS()) {
        return list;
      }

      if (str.charAt(idx) != '(') {
        throw new IllegalStateException();
      }

      ++idx; // skipping '('

      while (skipBlankAndComma()) {
        if (str.charAt(idx) == ')') {
          ++idx;
          return list;
        }
        list.add(readOne());
      }
      throw new IllegalArgumentException(
          String.format(
              "Syntax error parsing '%s' at char %d: unexpected end of string", str, idx));
    }

    Map<String, String> getCollectionsParameters() {
      if (isEOS()) {
        return Collections.emptyMap();
      }
      if (str.charAt(idx) != '(') {
        throw new IllegalStateException();
      }
      ++idx; // skipping '('
      return getNameAndTypeParameters();
    }

    // Must be at the start of the first parameter to read
    private Map<String, String> getNameAndTypeParameters() {
      // The order of the hashmap matters for UDT
      Map<String, String> map = new LinkedHashMap<>();

      while (skipBlankAndComma()) {
        if (str.charAt(idx) == ')') {
          ++idx;
          return map;
        }

        String bbHex = readNextIdentifier();
        String name = null;
        try {
          name =
              TypeCodecs.TEXT.decode(
                  Bytes.fromHexString("0x" + bbHex), DefaultProtocolVersion.DEFAULT);
        } catch (NumberFormatException e) {
          throwSyntaxError(e.getMessage());
        }

        skipBlank();
        if (str.charAt(idx) != ':') {
          throwSyntaxError("expecting ':' token");
        }

        ++idx;
        skipBlank();
        map.put(name, readOne());
      }
      throw new IllegalArgumentException(
          String.format(
              "Syntax error parsing '%s' at char %d: unexpected end of string", str, idx));
    }

    private void throwSyntaxError(String msg) {
      throw new IllegalArgumentException(
          String.format("Syntax error parsing '%s' at char %d: %s", str, idx, msg));
    }

    private boolean isEOS() {
      return isEOS(str, idx);
    }

    private static boolean isEOS(String str, int i) {
      return i >= str.length();
    }

    private void skipBlank() {
      idx = skipBlank(str, idx);
    }

    private static int skipBlank(String str, int i) {
      while (!isEOS(str, i) && ParseUtils.isBlank(str.charAt(i))) {
        ++i;
      }
      return i;
    }

    // skip all blank and at best one comma, return true if there not EOS
    private boolean skipBlankAndComma() {
      boolean commaFound = false;
      while (!isEOS()) {
        int c = str.charAt(idx);
        if (c == ',') {
          if (commaFound) {
            return true;
          } else {
            commaFound = true;
          }
        } else if (!ParseUtils.isBlank(c)) {
          return true;
        }
        ++idx;
      }
      return false;
    }

    // left idx positioned on the character stopping the read
    private String readNextIdentifier() {
      int i = idx;
      while (!isEOS() && ParseUtils.isCqlIdentifierChar(str.charAt(idx))) {
        ++idx;
      }
      return str.substring(i, idx);
    }

    @Override
    public String toString() {
      return str.substring(0, idx)
          + "["
          + (idx == str.length() ? "" : str.charAt(idx))
          + "]"
          + str.substring(idx + 1);
    }
  }

  @VisibleForTesting
  static ImmutableMap<String, DataType> NATIVE_TYPES_BY_CLASS_NAME =
      new ImmutableMap.Builder<String, DataType>()
          .put("org.apache.cassandra.db.marshal.AsciiType", DataTypes.ASCII)
          .put("org.apache.cassandra.db.marshal.LongType", DataTypes.BIGINT)
          .put("org.apache.cassandra.db.marshal.BytesType", DataTypes.BLOB)
          .put("org.apache.cassandra.db.marshal.BooleanType", DataTypes.BOOLEAN)
          .put("org.apache.cassandra.db.marshal.CounterColumnType", DataTypes.COUNTER)
          .put("org.apache.cassandra.db.marshal.DecimalType", DataTypes.DECIMAL)
          .put("org.apache.cassandra.db.marshal.DoubleType", DataTypes.DOUBLE)
          .put("org.apache.cassandra.db.marshal.FloatType", DataTypes.FLOAT)
          .put("org.apache.cassandra.db.marshal.InetAddressType", DataTypes.INET)
          .put("org.apache.cassandra.db.marshal.Int32Type", DataTypes.INT)
          .put("org.apache.cassandra.db.marshal.UTF8Type", DataTypes.TEXT)
          .put("org.apache.cassandra.db.marshal.TimestampType", DataTypes.TIMESTAMP)
          .put("org.apache.cassandra.db.marshal.SimpleDateType", DataTypes.DATE)
          .put("org.apache.cassandra.db.marshal.TimeType", DataTypes.TIME)
          .put("org.apache.cassandra.db.marshal.UUIDType", DataTypes.UUID)
          .put("org.apache.cassandra.db.marshal.IntegerType", DataTypes.VARINT)
          .put("org.apache.cassandra.db.marshal.TimeUUIDType", DataTypes.TIMEUUID)
          .put("org.apache.cassandra.db.marshal.ByteType", DataTypes.TINYINT)
          .put("org.apache.cassandra.db.marshal.ShortType", DataTypes.SMALLINT)
          .put("org.apache.cassandra.db.marshal.DurationType", DataTypes.DURATION)
          .build();
}
