/*
 * 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.avro;

import org.apache.avro.util.UtfTextUtils;

import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.io.Reader;
import java.net.URI;
import java.nio.charset.Charset;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import java.util.ServiceLoader;

/**
 * Avro schema parser for text-based formats like JSON, IDL, etc.
 *
 * <p>
 * Parses formatted (i.e., text based) schemata from a given source using the
 * available {@link FormattedSchemaParser} implementations, and returns the
 * first result. This means it can transparently handle any schema format. The
 * Avro project defines a JSON based format and an IDL format (the latter
 * available as a separate dependency), but you can also provide your own.
 * </p>
 *
 * <p>
 * The parser can handle various text based sources. If the source contains a
 * UTF encoded latin text based format it can even detect which UTF encoding was
 * used (UTF-8, UTF16BE, UTF16LE, UTF-32BE or UTF32LE).
 * </p>
 *
 * @see FormattedSchemaParser
 * @see UtfTextUtils
 */
public class SchemaParser {
  private final ParseContext parseContext;
  private final Collection<FormattedSchemaParser> formattedSchemaParsers;

  /**
   * <p>
   * Use a default SchemaParser to parse a single schema. Equivalent to:
   * </p>
   * <code>
   *   new SchemaParser().parse(schema).mainSchema()
   * </code>
   *
   * @param schema the formatted schema to parse
   * @return the parsed schema
   * @throws SchemaParseException when the schema is invalid
   * @see SchemaParser#parse(CharSequence)
   */
  public static Schema parseSingle(String schema) throws SchemaParseException {
    return new SchemaParser().parse(schema).mainSchema();
  }

  /**
   * <p>
   * Use a default SchemaParser to parse a single schema from a file. Equivalent
   * to:
   * </p>
   * <code>
   *   new SchemaParser().parse(schemaFile).mainSchema()
   * </code>
   *
   * @param schemaFile the formatted schema to parse
   * @return the parsed schema
   * @throws SchemaParseException when the schema is invalid
   * @see SchemaParser#parse(Path)
   */
  public static Schema parseSingle(Path schemaFile) throws SchemaParseException, IOException {
    return new SchemaParser().parse(schemaFile).mainSchema();
  }

  /**
   * Create a schema parser that validates names using
   * {@link NameValidator#UTF_VALIDATOR}. Initially, the list of known (named)
   * schemata is empty.
   */
  public SchemaParser() {
    this(NameValidator.UTF_VALIDATOR);
  }

  /**
   * Create a schema parser with the specified name validator.
   * <p>
   * Initially, the list of known (named) schemata is empty.
   * </p>
   * <p>
   * Note: using {@link NameValidator#STRICT_VALIDATOR} to validate names is
   * advised for maximum interoperability.
   * </p>
   *
   * @param nameValidator the name validator to use
   */
  public SchemaParser(NameValidator nameValidator) {
    NameValidator validator = nameValidator != null ? nameValidator : NameValidator.NO_VALIDATION;
    this.parseContext = new ParseContext(validator);
    this.formattedSchemaParsers = new ArrayList<>();
    for (FormattedSchemaParser formattedSchemaParser : ServiceLoader.load(FormattedSchemaParser.class)) {
      formattedSchemaParsers.add(formattedSchemaParser);
    }
    // Add the default JSON parser last (it is not registered as a service, even
    // though it implements the service interface), to allow implementations that
    // parse JSON files into schemata differently.
    formattedSchemaParsers.add(new JsonSchemaParser());
  }

  /**
   * Parse an Avro schema from a file. The file content is assumed to be UTF text.
   *
   * @param file the file to read
   * @return the schema
   * @throws IOException          when the schema cannot be read
   * @throws SchemaParseException if parsing the schema failed; contains
   *                              suppressed underlying parse exceptions if
   *                              available
   * @see UtfTextUtils UTF detection algorithm in UtfTextUtils
   */
  public ParseResult parse(File file) throws IOException, SchemaParseException {
    return parse(file, null);
  }

  /**
   * Parse an Avro schema from a file written with a specific character set.
   *
   * @param file    the file to read
   * @param charset the character set of the file contents
   * @return the schema
   * @throws IOException          when the schema cannot be read
   * @throws SchemaParseException if parsing the schema failed; contains
   *                              suppressed underlying parse exceptions if
   *                              available
   */
  public ParseResult parse(File file, Charset charset) throws IOException, SchemaParseException {
    return parse(file.toPath(), charset);
  }

  /**
   * Parse an Avro schema from a file. The file content is assumed to be UTF text.
   *
   * @param file the file to read
   * @return the schema
   * @throws IOException          when the schema cannot be read
   * @throws SchemaParseException if parsing the schema failed; contains
   *                              suppressed underlying parse exceptions if
   *                              available
   * @see UtfTextUtils UTF detection algorithm in UtfTextUtils
   */
  public ParseResult parse(Path file) throws IOException, SchemaParseException {
    return parse(file, null);
  }

  /**
   * Parse an Avro schema from a file written with a specific character set.
   *
   * @param file    the file to read
   * @param charset the character set of the file contents
   * @return the schema
   * @throws IOException          when the schema cannot be read
   * @throws SchemaParseException if parsing the schema failed; contains
   *                              suppressed underlying parse exceptions if
   *                              available
   */
  public ParseResult parse(Path file, Charset charset) throws IOException, SchemaParseException {
    URI inputDir = file.getParent().toUri();
    try (InputStream stream = Files.newInputStream(file)) {
      String formattedSchema = UtfTextUtils.readAllBytes(stream, charset);
      return parse(inputDir, formattedSchema);
    }
  }

  /**
   * Parse an Avro schema from a file written with a specific character set.
   *
   * @param location the location of the schema resource
   * @param charset  the character set of the schema resource
   * @return the schema
   * @throws IOException          when the schema cannot be read
   * @throws SchemaParseException if parsing the schema failed; contains
   *                              suppressed underlying parse exceptions if
   *                              available
   */
  public ParseResult parse(URI location, Charset charset) throws IOException, SchemaParseException {
    try (InputStream stream = location.toURL().openStream()) {
      String formattedSchema = UtfTextUtils.readAllBytes(stream, charset);
      return parse(location, formattedSchema);
    }
  }

  /**
   * Parse an Avro schema from an input stream. The stream content is assumed to
   * be UTF text. Note that the stream stays open after reading.
   *
   * @param in the stream to read
   * @return the schema
   * @throws IOException          when the schema cannot be read
   * @throws SchemaParseException if parsing the schema failed; contains
   *                              suppressed underlying parse exceptions if
   *                              available
   * @see UtfTextUtils UTF detection algorithm in UtfTextUtils
   */
  public ParseResult parse(InputStream in) throws IOException, SchemaParseException {
    return parse(in, null);
  }

  /**
   * Parse an Avro schema from an input stream. Note that the stream stays open
   * after reading.
   *
   * @param in      the stream to read
   * @param charset the character set of the stream contents
   * @return the schema
   * @throws IOException          when the schema cannot be read
   * @throws SchemaParseException if parsing the schema failed; contains
   *                              suppressed underlying parse exceptions if
   *                              available
   */
  public ParseResult parse(InputStream in, Charset charset) throws IOException, SchemaParseException {
    return parse(UtfTextUtils.readAllBytes(in, charset));
  }

  /**
   * Parse an Avro schema from an input reader.
   *
   * @param in the stream to read
   * @return the schema
   * @throws IOException          when the schema cannot be read
   * @throws SchemaParseException if parsing the schema failed; contains
   *                              suppressed underlying parse exceptions if
   *                              available
   */
  public ParseResult parse(Reader in) throws IOException, SchemaParseException {
    return parse(UtfTextUtils.readAllChars(in));
  }

  /**
   * Parse an Avro schema from a string.
   *
   * @param text the text to parse
   * @return the schema
   * @throws SchemaParseException if parsing the schema failed; contains
   *                              suppressed underlying parse exceptions if
   *                              available
   */
  public ParseResult parse(CharSequence text) throws SchemaParseException {
    try {
      return parse(null, text);
    } catch (IOException e) {
      // This can only happen if parser implementations try to read other (related)
      // schemata from somewhere.
      throw new AvroRuntimeException("Could not read schema", e);
    }
  }

  /**
   * Parse the given schema (string) within the specified context using all
   * available {@link FormattedSchemaParser} implementations, collecting any
   * {@link SchemaParseException}s that occur, and return the first successfully
   * parsed schema. If all parsers fail, throw a {@code SchemaParseException} with
   * all collected parse exceptions added as suppressed exceptions. Uses the base
   * location of the schema (e.g., the directory where the schema file lives) if
   * available.
   *
   * @param baseUri         the base location of the schema, or {@code null} if
   *                        not known
   * @param formattedSchema the schema as text
   * @return the parsed schema
   * @throws IOException          if thrown by one of the parsers
   * @throws RuntimeException     if thrown by one of the parsers
   * @throws SchemaParseException when all parsers fail
   */
  private ParseResult parse(URI baseUri, CharSequence formattedSchema) throws IOException, SchemaParseException {
    List<SchemaParseException> parseExceptions = new ArrayList<>();
    for (FormattedSchemaParser formattedSchemaParser : formattedSchemaParsers) {
      try {
        Schema schema = formattedSchemaParser.parse(parseContext, baseUri, formattedSchema);
        if (parseContext.hasNewSchemas() || schema != null) {
          // Parsing succeeded: return the result.
          return parseContext.commit(schema);
        }
      } catch (SchemaParseException e) {
        parseContext.rollback();
        parseExceptions.add(e);
      }
    }

    // None of the available parsers succeeded

    if (parseExceptions.size() == 1) {
      throw parseExceptions.get(0);
    }
    SchemaParseException parseException = new SchemaParseException(
        "Could not parse the schema (the suppressed exceptions tell why).");
    parseExceptions.forEach(parseException::addSuppressed);
    throw parseException;
  }

  /**
   * Get all parsed schemata.
   *
   * @return all parsed schemas, in the order they were parsed
   */
  public List<Schema> getParsedNamedSchemas() {
    return parseContext.resolveAllSchemas();
  }

  // Temporary method to reduce PR size
  @Deprecated
  public Schema resolve(ParseResult result) {
    return result.mainSchema();
  }

  public interface ParseResult {
    /**
     * The main schema parsed from a file. Can be any schema, or {@code null} if the
     * parsed file has no "main" schema.
     */
    Schema mainSchema();

    /**
     * The list of named schemata that were parsed.
     */
    List<Schema> parsedNamedSchemas();
  }
}
