package org.interledger.codecs;

import org.interledger.InterledgerPacket;
import org.interledger.InterledgerPacket.Handler;
import org.interledger.InterledgerPacket.VoidHandler;
import org.interledger.codecs.packettypes.InterledgerPacketType;

import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.util.Arrays;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.ConcurrentHashMap;

/**
 * A contextual object for matching instances of {@link Codec} to specific class types.
 */
public class CodecContext {

  /**
   * A map of codec that can encode/decode based on a typeId prefix. This is used when an
   * undetermined packet of bytes are coming in off the wire, and we need to determine how to decode
   * these bytes based on the first typeId header.
   */
  private final Map<InterledgerPacketType, Class<?>> packetCodecs;

  /**
   * A map of codecs that can encode/decode based on a class type. This is for encoding/decoding
   * objects that are part of a known packet layout.
   */
  private final Map<Class<?>, Codec<?>> codecs;

  /**
   * No-args Constructor.
   */
  public CodecContext() {
    this.packetCodecs = new ConcurrentHashMap<>();
    this.codecs = new ConcurrentHashMap<>();
  }


  /**
   * Register a converter associated to the supplied {@code type}.
   *
   * @param type      An instance of {link Class} of type {@link T}.
   * @param converter An instance of {@link Codec}.
   * @param <T>       An instance of {@link T}.
   *
   * @return A {@link CodecContext} for the supplied {@code type}.
   */
  public <T> CodecContext register(final Class<? extends T> type, final Codec<T> converter) {
    Objects.requireNonNull(type);
    Objects.requireNonNull(converter);

    this.codecs.put(type, converter);
    if (converter instanceof InterledgerPacketCodec<?>) {
      InterledgerPacketCodec<?> commandTypeConverter = (InterledgerPacketCodec) converter;
      this.packetCodecs.put(commandTypeConverter.getTypeId(), type);
    }
    return this;
  }

  /**
   * Read an {@link InterledgerPacket} from the {@code inputStream}.
   *
   * @param typeId      An instance of {@link InterledgerPacketType}.
   * @param inputStream An instance of {@link InputStream}
   *
   * @return An instance of {@link InterledgerPacket} as read from the input stream.
   *
   * @throws IOException If anything goes wrong reading from the {@code inputStream}.
   */
  public InterledgerPacket read(final InterledgerPacketType typeId, final InputStream inputStream)
      throws IOException {
    Objects.requireNonNull(inputStream);
    return (InterledgerPacket) lookup(typeId).read(this, inputStream);
  }

  /**
   * Helper method that accepts an {@link InputStream}, detects the type of the packet to be read
   * and decodes the packet to {@link InterledgerPacket}. Because {@link InterledgerPacket} is
   * simply a marker interface, callers might prefer to utilize the functionality supplied by {@link
   * #readAndHandle(InputStream, InterledgerPacket.Handler)} or
   * {@link #readAndHandle(InputStream, InterledgerPacket.VoidHandler)}.
   *
   * @param inputStream An instance of {@link InputStream} that contains bytes in a certain
   *                    encoding.
   *
   * @return An instance of {@link InterledgerPacket}.
   *
   * @throws IOException If anything goes wrong reading from the {@code inputStream}.
   */
  public InterledgerPacket read(final InputStream inputStream) throws IOException {
    Objects.requireNonNull(inputStream);

    final InterledgerPacketType type = InterledgerPacketType.fromTypeId(inputStream.read());
    return read(type, inputStream);
  }

  /**
   * Helper method that accepts an {@link InputStream} and a type hint, and then decodes the input
   * to the appropriate response payload.
   *
   * @param type        An instance of {@link Class} that indicates the type that should be
   *                    decoded.
   * @param inputStream An instance of {@link InputStream} that contains bytes in a certain
   *                    encoding.
   * @param <T>         The type of object to return, based upon the supplied type of {@code type}.
   *
   * @return An instance of {@link T}.
   *
   * @throws IOException If anything goes wrong reading from the {@code buffer}.
   */
  public <T> T read(final Class<T> type, final InputStream inputStream) throws IOException {
    Objects.requireNonNull(type);
    Objects.requireNonNull(inputStream);

    if (InterledgerPacket.class.isAssignableFrom(type)) {
      //noinspection ResultOfMethodCallIgnored
      inputStream.read(); // swallow type field
    }
    return lookup(type).read(this, inputStream);
  }

  /**
   * Helper method that accepts a {@link byte[]} and a type hint, and then decodes the input to the
   * appropriate response payload.
   *
   * <p>NOTE: This methods wraps IOExceptions in RuntimeExceptions.
   *
   * @param type An instance of {@link Class} that indicates the type that should be decoded.
   * @param data An instance of {@link byte[]} that contains bytes in a certain encoding.
   * @param <T>  The type of object to return, based upon the supplied type of {@code type}.
   *
   * @return An instance of {@link T}.
   */
  public <T> T read(final Class<T> type, final byte[] data) {
    Objects.requireNonNull(type);
    Objects.requireNonNull(data);

    try (ByteArrayInputStream bais = new ByteArrayInputStream(data)) {
      if (InterledgerPacket.class.isAssignableFrom(type)) {
        //noinspection ResultOfMethodCallIgnored
        bais.read(); // swallow type field
      }
      return lookup(type).read(this, bais);
    } catch (IOException e) {
      throw new CodecException("Unable to decode " + type.getCanonicalName(), e);
    }

  }

  /**
   * Read an object from the buffer according to the rules defined in the {@link CodecContext}, and
   * handle any terminating logic inside of {@code packetHandler}.
   *
   * @param inputStream   An instance of {@link InputStream} to read data from.
   * @param packetHandler A {@link InterledgerPacket.VoidHandler} that allows callers to supply
   *                      business logic to be applied against the packet, depending on what the
   *                      runtime-version of the packet ultimately is.
   *
   * @throws IOException If anything goes wrong while reading from the InputStream.
   */
  public void readAndHandle(final InputStream inputStream,
      final InterledgerPacket.VoidHandler packetHandler) throws IOException {

    Objects.requireNonNull(inputStream);
    Objects.requireNonNull(packetHandler);

    final InterledgerPacket interledgerPacket = this.read(inputStream);
    packetHandler.execute(interledgerPacket);
  }

  /**
   * Read an object from {@code inputStream} according to the rules defined in the {@code context},
   * handle any concrete logic inside of {@code packetHandler}, and return a result.
   *
   * @param <R>           This describes the type parameter of the object to be read.
   * @param inputStream   An instance of {@link InputStream} to read data from.
   * @param packetHandler A {@link InterledgerPacket.Handler} that allows callers to supply business
   *                      logic to be applied against the packet, depending on what the
   *                      runtime-version of the packet ultimately is, and then return a value.
   *
   * @return An instance of {@link R}.
   *
   * @throws IOException If anything goes wrong while reading from the InputStream.
   */
  public <R> R readAndHandle(final InputStream inputStream,
      final InterledgerPacket.Handler<R> packetHandler) throws IOException {
    Objects.requireNonNull(inputStream);
    Objects.requireNonNull(packetHandler);

    final InterledgerPacket interledgerPacket = this.read(inputStream);
    return packetHandler.execute(interledgerPacket);
  }

  /**
   * Writes an instance of {@code instance} to the supplied {@link OutputStream}.
   *
   * @param type         An instance of {@link Class} that indicates the type that should be
   *                     encoded.
   * @param instance     An instance of {@link T} that will be encoded to the output stream.
   * @param outputStream An instance of {@link OutputStream} that will be written to.
   * @param <T>          The type of object to encode.
   *
   * @return An instance of {@link CodecContext} for further operations.
   *
   * @throws IOException If anything goes wrong while writing to the {@link OutputStream}
   */
  public <T> CodecContext write(final Class<T> type, final T instance,
      final OutputStream outputStream) throws IOException {
    Objects.requireNonNull(type);
    Objects.requireNonNull(instance);
    Objects.requireNonNull(outputStream);

    lookup(type).write(this, instance, outputStream);
    return this;
  }

  /**
   * Writes a generic instance of {@code Object} to the supplied {@link OutputStream}.
   *
   * @param instance     An instance of {@link Object} that will be encoded to the output stream.
   * @param outputStream An instance of {@link OutputStream} that will be written to.
   *
   * @return An instance of {@link CodecContext} for further operations.
   *
   * @throws IOException If anything goes wrong while writing to the {@link OutputStream}
   */
  public CodecContext write(final Object instance, final OutputStream outputStream)
      throws IOException {
    Objects.requireNonNull(instance);
    Objects.requireNonNull(outputStream);

    lookup(instance.getClass()).writeObject(this, instance, outputStream);
    return this;
  }

  /**
   * Writes an instance of {@code instance} to an in-memory stream and returns the result as a
   * {@link byte[]}.
   *
   * <p>NOTE: This methods wraps any IOExceptions in a RuntimeException.
   *
   * @param type     An instance of {@link Class} that indicates the type that should be encoded.
   * @param instance An instance of {@link T} that will be encoded to the output stream.
   * @param <T>      The type of object to encode.
   *
   * @return The encoded object.
   */
  public <T> byte[] write(final Class<T> type, final T instance) {
    Objects.requireNonNull(type);
    Objects.requireNonNull(instance);

    try (ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
      lookup(type).write(this, instance, baos);
      return baos.toByteArray();
    } catch (IOException e) {
      throw new CodecException("Error encoding " + type.getCanonicalName(), e);
    }
  }

  /**
   * Writes a generic instance of {@code Object} to an in-memory stream and returns the result as a
   * {@link byte[]}.
   *
   * <p>NOTE: This methods wraps any IOExceptions in a RuntimeException.
   *
   * @param instance An instance of {@link Object} that will be encoded to the output stream.
   *
   * @return An instance of {@link CodecContext} for further operations.
   */
  public byte[] write(final Object instance) {
    Objects.requireNonNull(instance);

    try (ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
      lookup(instance.getClass()).writeObject(this, instance, baos);
      return baos.toByteArray();
    } catch (IOException e) {
      throw new RuntimeException("Error encoding " + instance.getClass());
    }
  }

  /**
   * Helper method to lookup a {@link Codec} for the specified {@code type}.
   *
   * @param type An instance of {@link Class}.
   * @param <T>  The specific type of {@link Codec} to return.
   */
  @SuppressWarnings("unchecked")
  private <T> Codec<T> lookup(final Class<T> type) {
    Objects.requireNonNull(type);

    if (codecs.containsKey(type)) {
      return (Codec<T>) codecs.get(type);
    } else if (codecs.containsKey(type.getSuperclass())) {
      return (Codec<T>) codecs.get(type.getSuperclass());
    } else {
      // Check for interfaces...
      return Arrays.stream(type.getInterfaces())
          .filter(codecs::containsKey)
          .map(interfaceClass -> (Codec<T>) codecs.get(interfaceClass))
          .findFirst()
          .orElseThrow(() -> new CodecException(
              String.format("No codec registered for %s or its super classes!",
                  type.getName())));
    }
  }

  /**
   * Lookup a specific {@link Codec} based upon the supplied {@code typeId}.
   *
   * @param typeId An instance of {@link InterledgerPacketType}.
   */
  private Codec<?> lookup(final InterledgerPacketType typeId) {
    if (packetCodecs.containsKey(typeId)) {
      return codecs.get(packetCodecs.get(typeId));
    }
    throw new CodecException(
        "No " + InterledgerPacketCodec.class.getName() + " registered for typeId " + typeId);
  }

  /**
   * Indicates if context has a registered {@link Codec} for the specified class.
   *
   * @param clazz An instance of {@link Class}.
   *
   * @return {@code true} if the supplied class has a registered codec, {@code false} otherwise.
   */
  public boolean hasRegisteredCodec(final Class<?> clazz) {
    Objects.requireNonNull(clazz);
    return codecs.containsKey(clazz);
  }

}