package de.digitalcollections.iiif.model.image;

import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonValue;
import com.google.common.base.Objects;
import java.awt.Dimension;
import java.awt.Rectangle;
import java.awt.geom.Rectangle2D;
import java.math.BigDecimal;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Stream;

public class RegionRequest {

  /** We use BigDecimals for the relative region, since we want to preserve the precision of the request **/
  private class RelativeBox {

    final BigDecimal x;
    final BigDecimal y;
    final BigDecimal w;
    final BigDecimal h;

    public RelativeBox(BigDecimal x, BigDecimal y, BigDecimal w, BigDecimal h) {
      this.x = x;
      this.y = y;
      this.w = w;
      this.h = h;
    }

    @Override
    public boolean equals(Object o) {
      if (this == o) {
        return true;
      }
      if (o == null || getClass() != o.getClass()) {
        return false;
      }
      RelativeBox that = (RelativeBox) o;
      return Objects.equal(x, that.x)
          && Objects.equal(y, that.y)
          && Objects.equal(w, that.w)
          && Objects.equal(h, that.h);
    }

    @Override
    public int hashCode() {
      return Objects.hashCode(x, y, w, h);
    }
  }

  private Rectangle absoluteBox;
  private RelativeBox relativeBox;
  private boolean square = false;

  private static final Pattern PARSE_PAT = Pattern.compile("^(pct:)?([0-9.]+),([0-9.]+),([0-9.]+),([0-9.]+)$");

  /**
   * Parse an IIIF Image API compliant region request string
   *
   * @param str region request string
   * @return resulting RegionRequest instance
   * @throws ResolvingException if the request string is malformed.
   */
  @JsonCreator
  public static RegionRequest fromString(String str) throws ResolvingException {
    if (str.equals("full")) {
      return new RegionRequest();
    }
    if (str.equals("square")) {
      return new RegionRequest(true);
    }
    Matcher matcher = PARSE_PAT.matcher(str);
    if (!matcher.matches()) {
      throw new ResolvingException("Bad format: " + str);
    }
    if (matcher.group(1) == null) {
      return new RegionRequest(
          Integer.valueOf(matcher.group(2)),
          Integer.valueOf(matcher.group(3)),
          Integer.valueOf(matcher.group(4)),
          Integer.valueOf(matcher.group(5)));
    } else {
      return new RegionRequest(
          new BigDecimal(matcher.group(2)),
          new BigDecimal(matcher.group(3)),
          new BigDecimal(matcher.group(4)),
          new BigDecimal(matcher.group(5)));
    }
  }

  /**
   * Create a region that encompasses the whole picture, i.e. the 'full' syntax.
   */
  public RegionRequest() {
    this(false);
  }

  /**
   * Pass 'true' to create a region that selects a square region from the image, i.e.the 'square' syntax.
   *
   * @param square true, if square region should be selected
   */
  public RegionRequest(boolean square) {
    this.square = square;
  }

  private RegionRequest(BigDecimal x, BigDecimal y, BigDecimal width, BigDecimal height) throws ResolvingException {
    if (Stream.of(x, y, width, height).anyMatch(v -> v.doubleValue() > 100.0)) {
      throw new ResolvingException("No parameter can be greater than 100!");
    }
    this.relativeBox = new RelativeBox(x, y, width, height);
  }

  /**
   * Create a RegionRequest request that is expressed using relative values, i.e.the "pct:x,y,w,h" syntax
   *
   * The values must be between 0.0 and 100.0.
   *
   * @param x relative upper left x position of region
   * @param y relative upper left y position of region
   * @param width relative width of region
   * @param height relative height of region
   * @throws ResolvingException if the values fall outside of the allowed range
   */
  public RegionRequest(double x, double y, double width, double height) throws ResolvingException {
    this(BigDecimal.valueOf(x), BigDecimal.valueOf(y), BigDecimal.valueOf(width), BigDecimal.valueOf(height));
  }

  /**
   * Create a RegionRequest request that is expressed using absolute values.
   *
   * @param x absolute upper left x position of region
   * @param y absolute upper left y position of region
   * @param width absolute width of region
   * @param height absolute height of region
   */
  public RegionRequest(int x, int y, int width, int height) {
    this.absoluteBox = new Rectangle(x, y, width, height);
  }

  /**
   * Returns the requested region
   *
   * @return get a Rectangle2D instance representing the region
   */
  public Rectangle2D getRegion() {
    if (isRelative()) {
      return new Rectangle2D.Double(
          relativeBox.x.doubleValue(), relativeBox.y.doubleValue(),
          relativeBox.w.doubleValue(), relativeBox.h.doubleValue());
    } else {
      return absoluteBox;
    }
  }

  /**
   * Returns whether the region is expressed in relative terms.
   *
   * @return true, if region is defined in relative values
   */
  public boolean isRelative() {
    return relativeBox != null;
  }

  /**
   * Returns whether a square region is selected.
   *
   * @return true, if region should be square
   */
  public boolean isSquare() {
    return square;
  }

  /**
   * Create an IIIF Image API compliant region request string
   *
   * @return iiif image api compliant region request string
   */
  @Override
  @JsonValue
  public String toString() {
    if (square) {
      return "square";
    }
    if (relativeBox == null && absoluteBox == null) {
      return "full";
    } else if (isRelative()) {
      return String.format("pct:%s,%s,%s,%s", relativeBox.x, relativeBox.y, relativeBox.w, relativeBox.h);
    } else {
      return String.format("%d,%d,%d,%d", absoluteBox.x, absoluteBox.y, absoluteBox.width, absoluteBox.height);
    }
  }

  public String getCanonicalForm(Dimension imageDims) throws ResolvingException {
    Rectangle resolved = this.resolve(imageDims);
    boolean isFull = resolved.x == 0
        && resolved.y == 0
        && resolved.width == imageDims.width
        && resolved.height == imageDims.height;
    if (isFull) {
      return "full";
    } else {
      return String.format("%d,%d,%d,%d", resolved.x, resolved.y, resolved.width, resolved.height);
    }
  }

  /**
   * Resolve the region request into an actual region that can be used for cropping the image
   *
   * @param imageDims actual image dimensions
   * @return Rectangle representing th actual region
   * @throws de.digitalcollections.iiif.model.image.ResolvingException if rectangle is outside image dimensions
   */
  public Rectangle resolve(Dimension imageDims) throws ResolvingException {
    if (square) {
      if (imageDims.width > imageDims.height) {
        return new Rectangle(
            (imageDims.width - imageDims.height) / 2,
            0,
            imageDims.height, imageDims.height);
      } else if (imageDims.height > imageDims.width) {
        return new Rectangle(
            0,
            (imageDims.height - imageDims.width) / 2,
            imageDims.width, imageDims.width);
      }
    }
    if (absoluteBox == null && relativeBox == null) {
      return new Rectangle(0, 0, imageDims.width, imageDims.height);
    }
    Rectangle rect;
    if (isRelative()) {
      rect = new Rectangle(
          (int) Math.round(relativeBox.x.doubleValue() / 100. * imageDims.getWidth()),
          (int) Math.round(relativeBox.y.doubleValue() / 100. * imageDims.getHeight()),
          (int) Math.round(relativeBox.w.doubleValue() / 100. * imageDims.getWidth()),
          (int) Math.round(relativeBox.h.doubleValue() / 100. * imageDims.getHeight()));
    } else {
      rect = absoluteBox;
    }
    if (rect.x >= imageDims.width || rect.y >= imageDims.height) {
      throw new ResolvingException("X and Y must be smaller than the native width/height");
    }
    if (rect.x + rect.width > imageDims.width) {
      rect.width = imageDims.width - rect.x;
    }
    if (rect.y + rect.height > imageDims.height) {
      rect.height = imageDims.height - rect.y;
    }
    return rect;
  }

  @Override
  public boolean equals(Object o) {
    if (this == o) {
      return true;
    }
    if (o == null || getClass() != o.getClass()) {
      return false;
    }
    RegionRequest that = (RegionRequest) o;
    return square == that.square
       && Objects.equal(absoluteBox, that.absoluteBox)
       && Objects.equal(relativeBox, that.relativeBox);
  }

  @Override
  public int hashCode() {
    return Objects.hashCode(absoluteBox, relativeBox, square);
  }
}


package de.digitalcollections.iiif.model.image;

import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.annotation.JsonTypeName;
import com.google.common.collect.ImmutableSet;
import de.digitalcollections.iiif.model.image.ImageApiProfile.Format;
import de.digitalcollections.iiif.model.image.ImageApiProfile.Quality;
import de.digitalcollections.iiif.model.interfaces.Selector;
import java.awt.Dimension;
import java.awt.geom.Rectangle2D;
import java.net.URI;
import java.util.Objects;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import org.dmfs.rfc3986.encoding.Encoded;
import org.dmfs.rfc3986.encoding.Precoded;

/**
 * A selector that describes a region on an IIIF Image API resource.
 *
 * See http://iiif.io/api/annex/openannotation/#iiif-image-api-selector
 */
@JsonTypeName(ImageApiSelector.TYPE)
public class ImageApiSelector implements Selector {

  public static String CONTEXT = "http://iiif.io/api/annex/openannotation/context.json";
  public static final String TYPE = "iiif:ImageApiSelector";

  private static final Pattern REQUEST_PAT = Pattern.compile(
      "/?(?<identifier>[^/]+)"
      + "/(?<region>[^/]+)"
      + "/(?<size>[^/]+)"
      + "/(?<rotation>[^/]+)"
      + "/(?<quality>[^/]+?)\\.(?<format>[^/]+?)$");

  private String identifier;
  private RegionRequest region;
  private SizeRequest size;
  private RotationRequest rotation;
  private Quality quality;
  private Format format;

  @JsonProperty("@context")
  public String getContext() {
    return CONTEXT;
  }

  @JsonProperty("@type")
  public String getType() {
    return TYPE;
  }

  public static ImageApiSelector fromImageApiUri(URI imageApiUri) throws ResolvingException {
    return fromString(imageApiUri.getPath());
  }

  public static ImageApiSelector fromString(String str) throws ResolvingException {
    Matcher matcher = REQUEST_PAT.matcher(str);
    if (!matcher.find()) {
      throw new ResolvingException("Malformed IIIF Image API request: " + str);
    }
    ImageApiSelector selector = new ImageApiSelector();
    selector.setIdentifier(new Precoded(matcher.group("identifier")).decoded().toString());
    selector.setRegion(matcher.group("region"));
    selector.setSize(matcher.group("size"));
    selector.setRotation(matcher.group("rotation"));
    try {
      selector.setQuality(Quality.valueOf(matcher.group("quality").toUpperCase()));
    } catch (IllegalArgumentException e) {
      throw new ResolvingException("Illegal value for quality: " + matcher.group("quality"));
    }
    try {
      selector.setFormat(Format.valueOf(matcher.group("format").toUpperCase()));
    } catch (IllegalArgumentException e) {
      throw new ResolvingException("Illegal value for format: " + matcher.group("format"));
    }
    return selector;
  }

  public URI asImageApiUri(URI baseUri) {
    String baseUriString = baseUri.toString();
    if (!baseUriString.endsWith("/")) {
      baseUriString = baseUriString + "/";
    }
    return URI.create(baseUriString + this.toString());
  }

  @Override
  public String toString() {
    return String.format(
        "%s%s/%s/%s/%s.%s",
        identifier != null ? urlEncode(identifier) + "/" : "",
        Objects.toString(region, "full"),
        Objects.toString(size, "full"),
        Objects.toString(rotation, "0"),
        Objects.toString(quality, "default"),
        Objects.toString(format, "jpg"));
  }

  /** The spec says we have to urlencode values, but only characters outside of the US ASCII range and gen-delims
   * from RFC3986. However, our URL library can only encode the full set of gen-delims (EXCEPT the colon) and sub-delims,
   * which iswhy we have to manually decode the encoded sub-delims...
   * Great and pragmatic choice for readability, more code for us :-) */
  private static String urlEncode(String str) {
    Set<String> excluded = ImmutableSet.of(
        ":", "!", "$", "&", "'", "(", ")", "*", "+", ",", ";", "=");
    String encoded = new Encoded(str).toString();
    for (String ex : excluded) {
      encoded = encoded.replaceAll(new Encoded(ex).toString(), ex);
    }
    return encoded;
  }

  /**
   *  Create the canonical of the Image API request.See http://iiif.io/api/image/2.1/#canonical-uri-syntax
   *
   * @param nativeSize      Native size of the image the selector is applied to
   * @param profile         Image API profile used
   * @param defaultQuality  The native/default quality of the image the selector is applied to
   * @return The canonical form of the Image API request
   * @throws de.digitalcollections.iiif.model.image.ResolvingException if RegionRequest can not be instantiated from canonical form of region
   */
  public String getCanonicalForm(Dimension nativeSize, ImageApiProfile profile, Quality defaultQuality) throws ResolvingException {
    Dimension scaleReference = nativeSize;
    Rectangle2D canonicalRegion = RegionRequest.fromString(region.getCanonicalForm(nativeSize)).getRegion();
    if (canonicalRegion != null) {
      scaleReference = new Dimension((int) canonicalRegion.getWidth(), (int) canonicalRegion.getHeight());
    }
    return String.format(
        "%s%s/%s/%s/%s.%s",
        identifier != null ? urlEncode(identifier) + "/" : "",
        region.getCanonicalForm(nativeSize),
        size.getCanonicalForm(scaleReference, profile),
        rotation.toString(),
        quality.equals(defaultQuality) ? "default" : quality.toString(),
        format.toString());
  }

  public String getIdentifier() {
    return identifier;
  }

  public void setIdentifier(String identifier) {
    this.identifier = identifier;
  }

  public RegionRequest getRegion() {
    return region;
  }

  public void setRegion(RegionRequest region) {
    this.region = region;
  }

  public void setRegion(String region) throws ResolvingException {
    this.region = RegionRequest.fromString(region);
  }

  public SizeRequest getSize() {
    return size;
  }

  public void setSize(SizeRequest size) {
    this.size = size;
  }

  public void setSize(String size) throws ResolvingException {
    this.size = SizeRequest.fromString(size);
  }

  public RotationRequest getRotation() {
    return rotation;
  }

  public void setRotation(RotationRequest rotation) {
    this.rotation = rotation;
  }

  public void setRotation(String rotation) throws ResolvingException {
    this.rotation = RotationRequest.fromString(rotation);
  }

  public Quality getQuality() {
    return quality;
  }

  public void setQuality(Quality quality) {
    this.quality = quality;
  }

  public Format getFormat() {
    return format;
  }

  public void setFormat(Format format) {
    this.format = format;
  }
}


package de.digitalcollections.iiif.model.jackson.serialization;

import com.fasterxml.jackson.core.JsonParser;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.core.JsonStreamContext;
import com.fasterxml.jackson.core.JsonToken;
import com.fasterxml.jackson.databind.DeserializationContext;
import com.fasterxml.jackson.databind.JsonDeserializer;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ArrayNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import com.google.common.collect.ImmutableMap;
import de.digitalcollections.iiif.model.ImageContent;
import de.digitalcollections.iiif.model.Motivation;
import de.digitalcollections.iiif.model.OtherContent;
import de.digitalcollections.iiif.model.openannotation.Annotation;
import de.digitalcollections.iiif.model.openannotation.ContentAsText;
import de.digitalcollections.iiif.model.openannotation.CssStyle;
import de.digitalcollections.iiif.model.openannotation.SpecificResource;
import de.digitalcollections.iiif.model.sharedcanvas.AnnotationList;
import de.digitalcollections.iiif.model.sharedcanvas.Canvas;
import de.digitalcollections.iiif.model.sharedcanvas.Collection;
import de.digitalcollections.iiif.model.sharedcanvas.Layer;
import de.digitalcollections.iiif.model.sharedcanvas.Manifest;
import de.digitalcollections.iiif.model.sharedcanvas.Range;
import de.digitalcollections.iiif.model.sharedcanvas.Resource;
import de.digitalcollections.iiif.model.sharedcanvas.Sequence;
import java.io.IOException;

/** Custom deserializer for Resource types.
 *
 * Needed since we need to preserve the @type for OtherContent, which would get lost
 * if we left the type resolving to Jackson.
 */
public class ResourceDeserializer extends JsonDeserializer<Resource> {

  private static final ImmutableMap<String, Class<? extends Resource>> MAPPING = new ImmutableMap.Builder<String, Class<? extends Resource>>()
      .put(Annotation.TYPE, Annotation.class)
      .put(AnnotationList.TYPE, AnnotationList.class)
      .put(Canvas.TYPE, Canvas.class)
      .put(Collection.TYPE, Collection.class)
      .put(Layer.TYPE, Layer.class)
      .put(Manifest.TYPE, Manifest.class)
      .put(Range.TYPE, Range.class)
      .put(Sequence.TYPE, Sequence.class)
      .put(ImageContent.TYPE, ImageContent.class)
      .put(SpecificResource.TYPE, SpecificResource.class)
      .put(ContentAsText.TYPE, ContentAsText.class)
      .put(CssStyle.TYPE, CssStyle.class)
      .build();

  public Resource deserialize(JsonParser p, DeserializationContext ctxt) throws IOException {
    ObjectMapper mapper = (ObjectMapper) p.getCodec();
    String containingField = getContainingField(p);
    if (p.getCurrentToken() == JsonToken.START_OBJECT) {
      ObjectNode obj = mapper.readTree(p);
      String typeName = getTypeName(containingField, ctxt, obj);
      if (typeName.equals("oa:Choice")) {
        return parseChoice(containingField, mapper, obj, ctxt);
      } else {
        return mapper.treeToValue(obj, MAPPING.getOrDefault(typeName, OtherContent.class));
      }
    } else if (p.getCurrentToken() == JsonToken.VALUE_STRING) {
      String stringValue = p.getValueAsString();
      String typeName = getMissingType(ctxt, containingField);
      return resourceFromString(MAPPING.getOrDefault(typeName, OtherContent.class),
                                stringValue);
    } else if (p.getCurrentToken() == JsonToken.START_ARRAY) {
      // TODO
      throw new RuntimeException("Could not deserialize Resource.");
    } else {
      throw new RuntimeException("Could not deserialize Resource.");
    }
  }

  private String getTypeName(String containingField, DeserializationContext ctxt, ObjectNode obj) {
    JsonNode typeNode = obj.get("@type");
    if (typeNode.isMissingNode()) {
      return getMissingType(ctxt, containingField);
    } else {
      return typeNode.textValue();
    }
  }

  private Resource parseChoice(String containingField, ObjectMapper mapper, ObjectNode tree,
                               DeserializationContext ctxt) throws JsonProcessingException {
    ObjectNode defaultTree = (ObjectNode) tree.get("default");
    Class<? extends Resource> resourceType = MAPPING.getOrDefault(
        getTypeName(containingField, ctxt, defaultTree), OtherContent.class);
    Resource defaultResource = mapper.treeToValue(defaultTree, resourceType);
    ArrayNode alternativesArray = (ArrayNode) tree.get("item");
    for (JsonNode subNode : alternativesArray) {
      defaultResource.addAlternative(mapper.treeToValue(subNode, resourceType));
    }
    return defaultResource;
  }

  private String getMissingType(DeserializationContext ctxt, String containingField) {
    switch (containingField) {
      case "on":
        return getOnType(ctxt);
      case "within":
        return getWithinType(ctxt);
      case "canvases":
        return "sc:Canvas";
      case "thumbnail":
      case "logo":
        return "dctypes:Image";
      default:
        return null;
    }
  }

  private String getWithinType(DeserializationContext ctxt) {
    Resource obj = (Resource) ctxt.getParser().getCurrentValue();
    if (obj instanceof Manifest || obj instanceof Collection) {
      return "sc:Collection";
    } else if (obj instanceof AnnotationList) {
      return "sc:Layer";
    } else {
      return null;
    }
  }

  /** Get type for  "on" values that are plain URIs by deducing the type from their parent. */
  private String getOnType(DeserializationContext ctxt) {
    // Easiest way: The parser has already constructed an annotation object with a motivation.
    // This is highly dependendant on the order of keys in the JSON, i.e. if "on" is the first key in the annotation
    // object, this won't work.
    Object curVal = ctxt.getParser().getCurrentValue();
    boolean isPaintingAnno = (curVal != null && curVal instanceof Annotation
                              && ((Annotation) curVal).getMotivation() != null
                              && ((Annotation) curVal).getMotivation().equals(Motivation.PAINTING));
    if (isPaintingAnno) {
      return "sc:Canvas";
    }
    // More reliable way: Walk up the parsing context until we hit a IIIF resource that we can deduce the type from
    // Usually this shouldn't be more than two levels up
    JsonStreamContext parent = ctxt.getParser().getParsingContext().getParent();
    while (parent != null && (parent.getCurrentValue() == null || !(parent.getCurrentValue() instanceof Resource))) {
      parent = parent.getParent();
    }
    if (parent != null) {
      Resource parentObj = (Resource) parent.getCurrentValue();
      return parentObj.getType();
    }
    return null;
  }

  private Resource resourceFromString(Class<? extends Resource> clz, String resourceString) {
    try {
      return clz.getConstructor(String.class).newInstance(resourceString);
    } catch (Exception e) {
      throw new IllegalArgumentException(String.format(
          "Could not construct %s from '%s'",
          clz.getName(), resourceString));
    }
  }

  private static String getContainingField(JsonParser parser) {
    final JsonStreamContext ctx = parser.getParsingContext();
    if (ctx.inArray()) {
      return ctx.getParent().getCurrentName();
    } else {
      return ctx.getCurrentName();
    }
  }
}


package de.digitalcollections.iiif.model;

import com.fasterxml.jackson.annotation.JsonIgnore;
import java.lang.reflect.Method;
import java.lang.reflect.Modifier;
import java.util.Arrays;
import java.util.Set;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import org.reflections.ReflectionUtils;

/**
 * Some static utility methods used for (de-)serialization and sanity checks.
 */
public class ModelUtilities {

  public enum Completeness {
    EMPTY, ID_ONLY, ID_AND_TYPE, ID_AND_TYPE_AND_LABEL, COMPLEX
  }

  private static boolean containsOnly(Set<String> vals, String... toCheck) {
    return (vals.size() == toCheck.length
            && Arrays.stream(toCheck).allMatch(vals::contains));
  }

  /**
   * Obtain the "completeness" (i.e. "empty", "id and type", "it, type and label", "id only" or "complex") of
   * a IIIF resource. Can be useful for determining how to serialize the resource, e.g. often resources with only
   * an id are serialized as a string.
   *
   * @param res The IIIF resource to check the completeness of
   * @param type The type of the IIIF resource
   * @return the completeness
   */
  public static Completeness getCompleteness(Object res, Class<?> type) {
    Set<Method> getters = ReflectionUtils.getAllMethods(
        type,
        ReflectionUtils.withModifier(Modifier.PUBLIC),
        ReflectionUtils.withPrefix("get"));
    Set<String> gettersWithValues = getters.stream()
        .filter(g -> g.getAnnotation(JsonIgnore.class) == null) // Only JSON-serializable fields
        .filter(g -> returnsValue(g, res))
        .map(Method::getName)
        .collect(Collectors.toSet());

    boolean hasOnlyTypeAndId = (gettersWithValues.size() == 2
                                && Stream.of("getType", "getIdentifier").allMatch(gettersWithValues::contains));
    if (gettersWithValues.isEmpty()) {
      return Completeness.EMPTY;
    } else if (containsOnly(gettersWithValues, "getType", "getIdentifier")) {
      return Completeness.ID_AND_TYPE;
    } else if (containsOnly(gettersWithValues, "getType", "getIdentifier", "getLabels")) {
      return Completeness.ID_AND_TYPE_AND_LABEL;
    } else if (containsOnly(gettersWithValues, "getIdentifier")) {
      return Completeness.ID_ONLY;
    } else {
      return Completeness.COMPLEX;
    }
  }

  private static boolean returnsValue(Method method, Object obj) {
    try {
      return method.invoke(obj) != null;
    } catch (Exception e) {
      return false;
    }
  }
}


package de.digitalcollections.iiif.model.sharedcanvas;

import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.annotation.JsonPropertyOrder;
import com.google.common.collect.ImmutableSet;
import com.google.common.collect.Lists;
import de.digitalcollections.iiif.model.ImageContent;
import de.digitalcollections.iiif.model.MetadataEntry;
import de.digitalcollections.iiif.model.OtherContent;
import de.digitalcollections.iiif.model.PropertyValue;
import de.digitalcollections.iiif.model.Service;
import de.digitalcollections.iiif.model.enums.ViewingHint;
import de.digitalcollections.iiif.model.jackson.SerializerModifier;
import de.digitalcollections.iiif.model.openannotation.Choice;
import java.net.URI;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Set;

import static com.google.common.collect.Lists.asList;

/**
 * Abstract IIIF resource, most other resources are based on this.
 *
 * @param <T> a resource type, e.g. Canvas, Annotation
 */
@JsonPropertyOrder({"@context", "@id", "@type", "label", "description", "metadata", "thumbnail", "service"})
public abstract class Resource<T> implements Choice<T> {

  public static final String CONTEXT = "http://iiif.io/api/presentation/2/context.json";

  /** Only used during serialization,
   *  @see SerializerModifier **/
  @SuppressWarnings("checkstyle:membername")
  @JsonProperty("@context")
  public String _context;

  @JsonProperty("@id")
  private URI identifier;

  private PropertyValue label;

  private PropertyValue description;

  @JsonIgnore
  private boolean isDefault;

  @JsonIgnore
  private List<T> alternatives;

  @JsonProperty("service")
  private List<Service> services;

  @JsonProperty("thumbnail")
  private List<ImageContent> thumbnails;

  @JsonProperty("attribution")
  private PropertyValue attribution;

  @JsonProperty("license")
  private List<URI> licenses;

  @JsonProperty("logo")
  private List<ImageContent> logos;

  private List<MetadataEntry> metadata;

  @JsonProperty("viewingHint")
  private List<ViewingHint> viewingHints;

  @JsonProperty("related")
  private List<OtherContent> related;

  @JsonProperty("rendering")
  private List<OtherContent> renderings;

  @JsonProperty("seeAlso")
  private List<OtherContent> seeAlsoContents;

  @JsonProperty("within")
  private List<Resource> within;

  public Resource() {
    this.identifier = null;
  }

  @JsonCreator
  public Resource(@JsonProperty("@id") String identifier) {
    if (identifier != null) {
      this.identifier = URI.create(identifier);
    } else {
      this.identifier = null;
    }
  }

  @JsonProperty("@type")
  public String getType() {
    return null;  // Does not have a type
  }

  public URI getIdentifier() {
    return identifier;
  }

  protected void setIdentifier(URI identifier) {
    this.identifier = identifier;
  }

  public List<Service> getServices() {
    return this.services;
  }

  public void setServices(List<Service> services) {
    this.services = services;
  }

  public Resource addService(Service first, Service... rest) {
    if (this.services == null) {
      this.services = new ArrayList<>();
    }
    this.services.addAll(Lists.asList(first, rest));
    return this;
  }

  public List<ImageContent> getThumbnails() {
    return thumbnails;
  }

  @JsonIgnore
  public ImageContent getThumbnail() {
    if (thumbnails == null || thumbnails.isEmpty()) {
      return null;
    }
    return thumbnails.get(0);
  }

  public void setThumbnails(List<ImageContent> thumbnails) {
    this.thumbnails = thumbnails;
  }

  public Resource addThumbnail(ImageContent... thumbnails) {
    if (this.thumbnails == null) {
      this.thumbnails = new ArrayList<>();
    }
    this.thumbnails.addAll(Arrays.asList(thumbnails));
    return this;
  }

  public List<MetadataEntry> getMetadata() {
    return metadata;
  }

  public Resource addMetadata(MetadataEntry... meta) {
    if (this.metadata == null) {
      this.metadata = new ArrayList<>();
    }
    this.metadata.addAll(Arrays.asList(meta));
    return this;
  }

  public Resource addMetadata(String label, String value) {
    return this.addMetadata(new MetadataEntry(label, value));
  }

  public PropertyValue getLabel() {
    return label;
  }

  @JsonIgnore
  public String getLabelString() {
    return label.getFirstValue();
  }

  public void setLabel(PropertyValue label) {
    this.label = label;
  }

  public Resource addLabel(String first, String... rest) {
    if (this.label == null) {
      this.label = new PropertyValue();
    }
    this.label.addValue(first, rest);
    return this;
  }

  public PropertyValue getDescription() {
    return description;
  }

  @JsonIgnore
  public String getDescriptionString() {
    return description.getFirstValue();
  }

  public void setDescription(PropertyValue description) {
    this.description = description;
  }

  public Resource addDescription(String first, String... rest) {
    if (this.description == null) {
      this.description = new PropertyValue();
    }
    this.description.addValue(first, rest);
    return this;
  }

  public PropertyValue getAttribution() {
    return attribution;
  }

  @JsonIgnore
  public String getAttributionString() {
    return attribution.getFirstValue();
  }

  public void setAttribution(PropertyValue attribution) {
    this.attribution = attribution;
  }

  public Resource addAttribution(String first, String... rest) {
    if (this.attribution == null) {
      this.attribution = new PropertyValue();
    }
    this.attribution.addValue(first, rest);
    return this;
  }

  public List<URI> getLicenses() {
    return licenses;
  }

  @JsonIgnore
  public URI getFirstLicense() {
    if (licenses == null || licenses.isEmpty()) {
      return null;
    }
    return licenses.get(0);
  }

  public void setLicenses(List<URI> licenses) {
    this.licenses = licenses;
  }

  public Resource addLicense(String first, String... rest) {
    if (this.licenses == null) {
      this.licenses = new ArrayList<>();
    }
    this.licenses.add(URI.create(first));
    Arrays.stream(rest).map(URI::create).forEach(licenses::add);
    return this;
  }

  public List<ImageContent> getLogos() {
    return logos;
  }

  @JsonIgnore
  public URI getLogoUri() {
    if (logos == null || logos.isEmpty()) {
      return null;
    }
    return logos.get(0).getIdentifier();
  }

  public void setLogos(List<ImageContent> logos) {
    this.logos = logos;
  }

  public Resource addLogo(String first, String... rest) {
    if (this.logos == null) {
      this.logos = new ArrayList<>();
    }
    this.logos.add(new ImageContent(first));
    Arrays.stream(rest).map(ImageContent::new).forEach(logos::add);
    return this;
  }

  public Resource addLogo(ImageContent first, ImageContent... rest) {
    if (this.logos == null) {
      this.logos = new ArrayList<>();
    }
    this.logos.addAll(Lists.asList(first, rest));
    return this;
  }

  @JsonIgnore
  public Set<ViewingHint.Type> getSupportedViewingHintTypes() {
    return ImmutableSet.of();
  }

  public List<ViewingHint> getViewingHints() {
    return viewingHints;
  }

  /**
   * Set the viewing hints for this resource.
   *
   * @param viewingHints list of viewing hints
   * @throws IllegalArgumentException if the resources not not support one of the viewing hints.
   */
  public void setViewingHints(List<ViewingHint> viewingHints) throws IllegalArgumentException {
    for (ViewingHint hint : viewingHints) {
      boolean supportsHint = (hint.getType() == ViewingHint.Type.OTHER
                              || this.getSupportedViewingHintTypes().contains(hint.getType()));
      if (!supportsHint) {
        throw new IllegalArgumentException(String.format(
            "Resources of type '%s' do not support the '%s' viewing hint.",
            this.getType(), hint.toString()));
      }
    }
    this.viewingHints = viewingHints;
  }

  /**
   * Add one or more viewing hints for this resource.
   *
   * @param first first viewing hint to add
   * @param rest list of second and more viewing hints
   * @return this resource with added viewing hints
   * @throws IllegalArgumentException if the resources not not support one of the viewing hints.
   */
  public Resource addViewingHint(ViewingHint first, ViewingHint... rest) throws IllegalArgumentException {
    List<ViewingHint> hints = this.viewingHints;
    if (hints == null) {
      hints = new ArrayList<>();
    }
    hints.addAll(Lists.asList(first, rest));
    this.setViewingHints(hints);
    return this;
  }

  public List<OtherContent> getRelated() {
    return related;
  }

  public void setRelated(List<OtherContent> related) {
    this.related = related;
  }

  public Resource addRelated(OtherContent first, OtherContent... rest) {
    if (related == null) {
      this.related = new ArrayList<>();
    }
    this.related.addAll(asList(first, rest));
    return this;
  }

  public List<OtherContent> getRenderings() {
    return renderings;
  }

  /**
   * Sets the renderings. All renderings must have both a profile and a format.
   *
   * @param renderings list of renderings with profile and format
   * @throws IllegalArgumentException if at least one rendering does not have both a profile and a format.
   */
  public void setRenderings(List<OtherContent> renderings) throws IllegalArgumentException {
    renderings.forEach(this::verifyRendering);
    this.renderings = renderings;
  }

  /**
   * Add one or more renderings. All renderings must have both a profile and a format.
   *
   * @param first first rendering
   * @param rest list of other renderings
   * @return current instance
   * @throws IllegalArgumentException if at least one rendering does not have both a profile and a format.
   */
  public Resource addRendering(OtherContent first, OtherContent... rest) {
    if (renderings == null) {
      this.renderings = new ArrayList<>();
    }
    List<OtherContent> renderingsToAdd = Lists.asList(first, rest);
    renderingsToAdd.forEach(this::verifyRendering);
    this.renderings.addAll(renderingsToAdd);
    return this;
  }

  public void verifyRendering(OtherContent content) {
    if (content.getLabel() == null || content.getFormat() == null) {
      throw new IllegalArgumentException("Rendering resources must have a label and format set.");
    }
  }

  public List<OtherContent> getSeeAlso() {
    return seeAlsoContents;
  }

  public void setSeeAlso(List<OtherContent> seeAlso) {
    this.seeAlsoContents = seeAlso;
  }

  public Resource addSeeAlso(OtherContent first, OtherContent... rest) {
    if (seeAlsoContents == null) {
      this.seeAlsoContents = new ArrayList<>();
    }
    this.seeAlsoContents.addAll(asList(first, rest));
    return this;
  }

  public List<Resource> getWithin() {
    return within;
  }

  public void setWithin(List<Resource> within) {
    this.within = within;
  }

  public Resource addWithin(Resource first, Resource... rest) {
    if (within == null) {
      this.within = new ArrayList<>();
    }
    this.within.addAll(asList(first, rest));
    return this;
  }

  @Override
  public String toString() {
    return String.format("Resource(type='%s',id='%s')", getType(), getIdentifier());
  }

  @JsonIgnore
  public boolean isDefaultChoice() {
    return this.isDefault;
  }

  public void setIsDefaultChoice(boolean is) {
    this.isDefault = is;
  }

  @Override
  public List<T> getAlternatives() {
    return alternatives;
  }

  @Override
  public void setAlternatives(List<T> alternatives) {
    this.alternatives = alternatives;
  }
}


package de.digitalcollections.iiif.model.image;

import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.annotation.JsonValue;
import com.google.common.base.CaseFormat;
import com.google.common.collect.ImmutableSet;
import com.google.common.collect.Lists;
import de.digitalcollections.iiif.model.Profile;
import de.digitalcollections.model.api.identifiable.resource.MimeType;
import java.net.URI;
import java.util.Collection;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Objects;
import java.util.Set;
import java.util.stream.Stream;

/**
 * An Image API profile.
 *
 * Can be either a simple pre-defined profile (e.g. {@link ImageApiProfile#LEVEL_ZERO}) or a complex profile
 * describing the available features of a given IIIF image.
 *
 * See http://iiif.io/api/image/2.1/#profile-description for an overview of all available properties and features
 * and http://iiif.io/api/image/2.1/compliance/ for an overview of the available compliance levels.
 */
public class ImageApiProfile extends Profile {

  public enum Format {
    JPG(MimeType.fromTypename("image/jpeg")),
    TIF(MimeType.fromTypename("image/tiff")),
    PNG(MimeType.fromTypename("image/png")),
    GIF(MimeType.fromTypename("image/gif")),
    JP2(MimeType.fromTypename("image/jp2")),
    PDF(MimeType.fromTypename("application/pdf")),
    WEBP(MimeType.fromTypename("image/webp"));

    private final MimeType mimeType;

    Format(MimeType mimeType) {
      this.mimeType = mimeType;
    }

    public MimeType getMimeType() {
      return mimeType;
    }

    @JsonValue
    @Override
    public String toString() {
      return CaseFormat.UPPER_UNDERSCORE.to(CaseFormat.LOWER_CAMEL, this.name());
    }
  }

  public enum Quality {
    COLOR, GRAY, BITONAL, DEFAULT;

    @JsonValue
    @Override
    public String toString() {
      return CaseFormat.UPPER_UNDERSCORE.to(CaseFormat.LOWER_CAMEL, this.name());
    }
  }

  public static class Feature {

    private enum ImageApiFeature {
      BASE_URI_REDIRECT, CANONICAL_LINK_HEADER, CORS, JSONLD_MEDIA_TYPE, MIRRORING, PROFILE_LINK_HEADER,
      REGION_BY_PCT, REGION_BY_PX, REGION_SQUARE, ROTATION_ARBITRARY, ROTATION_BY_90S, SIZE_ABOVE_FULL,
      SIZE_BY_WH_LISTED, SIZE_BY_FORCED_WH, SIZE_BY_H, SIZE_BY_PCT, SIZE_BY_W, SIZE_BY_WH, SIZE_BY_CONFINED_WH,
      SIZE_BY_DISTORTED_WH, OTHER
    }

    /** The base URI of the service will redirect to the image information document. **/
    public static final Feature BASE_URI_REDIRECT = new Feature(ImageApiFeature.BASE_URI_REDIRECT);

    /** The canonical image URI HTTP link header is provided on image responses. **/
    public static final Feature CANONICAL_LINK_HEADER = new Feature(ImageApiFeature.CANONICAL_LINK_HEADER);

    /** The CORS HTTP header is provided on all responses. **/
    public static final Feature CORS = new Feature(ImageApiFeature.CORS);

    /** The JSON-LD media type is provided when JSON-LD is requested. **/
    public static final Feature JSONLD_MEDIA_TYPE = new Feature(ImageApiFeature.JSONLD_MEDIA_TYPE);

    /** The image may be rotated around the vertical axis, resulting in a left-to-right mirroring of the content. **/
    public static final Feature MIRRORING = new Feature(ImageApiFeature.MIRRORING);

    /** The profile HTTP link header is provided on image responses. **/
    public static final Feature PROFILE_LINK_HEADER = new Feature(ImageApiFeature.PROFILE_LINK_HEADER);

    /** Regions of images may be requested by percentage. **/
    public static final Feature REGION_BY_PCT = new Feature(ImageApiFeature.REGION_BY_PCT);

    /** Regions of images may be requested by pixel dimensions. **/
    public static final Feature REGION_BY_PX = new Feature(ImageApiFeature.REGION_BY_PX);

    /** A square region where the width and height are equal to the shorter dimension of the complete image content. **/
    public static final Feature REGION_SQUARE = new Feature(ImageApiFeature.REGION_SQUARE);

    /** Rotation of images may be requested by degrees other than multiples of 90. **/
    public static final Feature ROTATION_ARBITRARY = new Feature(ImageApiFeature.ROTATION_ARBITRARY);

    /** Rotation of images may be requested by degrees in multiples of 90. **/
    public static final Feature ROTATION_BY_90S = new Feature(ImageApiFeature.ROTATION_BY_90S);

    /** Size of images may be requested larger than the “full” size. See warning. **/
    public static final Feature SIZE_ABOVE_FULL = new Feature(ImageApiFeature.SIZE_ABOVE_FULL);

    /** Size of images may be requested in the form “!w,h”. **/
    public static final Feature SIZE_BY_CONFINED_WH = new Feature(ImageApiFeature.SIZE_BY_CONFINED_WH);

    /** Size of images may be requested in the form “w,h”, including sizes that would distort the image. **/
    public static final Feature SIZE_BY_DISTORTED_WH = new Feature(ImageApiFeature.SIZE_BY_DISTORTED_WH);

    /** Size of images may be requested in the form “,h”. **/
    public static final Feature SIZE_BY_H = new Feature(ImageApiFeature.SIZE_BY_H);

    /** Size of images may be requested in the form “pct:n”.**/
    public static final Feature SIZE_BY_PCT = new Feature(ImageApiFeature.SIZE_BY_PCT);

    /** Size of images may be requested in the form “w,”. **/
    public static final Feature SIZE_BY_W = new Feature(ImageApiFeature.SIZE_BY_W);

    /** Size of images may be requested in the form “w,h” where the supplied w and h preserve the aspect ratio. **/
    public static final Feature SIZE_BY_WH = new Feature(ImageApiFeature.SIZE_BY_WH);

    @Deprecated
    public static final Feature SIZE_BY_WH_LISTED = new Feature(ImageApiFeature.SIZE_BY_WH_LISTED);

    @Deprecated
    public static final Feature SIZE_BY_FORCED_WH = new Feature(ImageApiFeature.SIZE_BY_FORCED_WH);

    private final ImageApiFeature imageApiFeature;
    private final URI customFeature;

    @JsonCreator
    public Feature(String featureName) {
      if (featureName.startsWith("http://") || featureName.startsWith("https://")) {
        this.imageApiFeature = ImageApiFeature.OTHER;
        this.customFeature = URI.create(featureName);
      } else {
        String name = CaseFormat.LOWER_CAMEL.to(CaseFormat.UPPER_UNDERSCORE, featureName);
        name = name.replaceAll("([A-Z])(\\d)", "$1_$2");
        this.imageApiFeature = ImageApiFeature.valueOf(name);
        this.customFeature = null;
      }
    }

    private Feature(ImageApiFeature feature) {
      this.imageApiFeature = feature;
      this.customFeature = null;
    }

    @JsonValue
    @Override
    public String toString() {
      if (this.imageApiFeature == ImageApiFeature.OTHER) {
        return this.customFeature.toString();
      } else {
        return CaseFormat.UPPER_UNDERSCORE.to(CaseFormat.LOWER_CAMEL, this.imageApiFeature.name());
      }
    }

    @Override
    public boolean equals(Object obj) {
      if (!(obj instanceof Feature)) {
        return false;
      }
      Feature other = (Feature) obj;
      return Objects.equals(this.imageApiFeature, other.imageApiFeature)
             && Objects.equals(this.customFeature, other.customFeature);
    }
  }

  public static final ImageApiProfile LEVEL_ZERO = new ImageApiProfile("http://iiif.io/api/image/2/level0.json");

  static {
    LEVEL_ZERO.addFormat(Format.JPG);
    LEVEL_ZERO.addQuality(Quality.DEFAULT);
  }

  public static final ImageApiProfile LEVEL_ONE = new ImageApiProfile("http://iiif.io/api/image/2/level1.json");

  static {
    LEVEL_ONE.addFeature(
        Feature.REGION_BY_PX,
        Feature.SIZE_BY_W,
        Feature.SIZE_BY_H,
        Feature.SIZE_BY_PCT,
        Feature.BASE_URI_REDIRECT,
        Feature.CORS,
        Feature.JSONLD_MEDIA_TYPE);
    LEVEL_ONE.addFormat(Format.JPG);
    LEVEL_ONE.addQuality(Quality.DEFAULT);
  }

  public static final ImageApiProfile LEVEL_TWO = new ImageApiProfile("http://iiif.io/api/image/2/level2.json");

  static {
    LEVEL_TWO.addFeature(
        Feature.REGION_BY_PX,
        Feature.REGION_BY_PCT,
        Feature.SIZE_BY_W,
        Feature.SIZE_BY_H,
        Feature.SIZE_BY_PCT,
        Feature.SIZE_BY_CONFINED_WH,
        Feature.SIZE_BY_DISTORTED_WH,
        Feature.SIZE_BY_WH,
        Feature.ROTATION_BY_90S,
        Feature.BASE_URI_REDIRECT,
        Feature.CORS,
        Feature.JSONLD_MEDIA_TYPE);
    LEVEL_TWO.addFormat(Format.JPG, Format.PNG);
    LEVEL_TWO.addQuality(Quality.DEFAULT, Quality.COLOR, Quality.GRAY, Quality.BITONAL);
  }

  public static final Set<String> V1_PROFILES = ImmutableSet.of(
      "http://library.stanford.edu/iiif/image-api/compliance.html#level0",
      "http://library.stanford.edu/iiif/image-api/1.1/compliance.html#level0",
      "http://library.stanford.edu/iiif/image-api/1.1/conformance.html#level0",
      "http://library.stanford.edu/iiif/image-api/compliance.html#level1",
      "http://library.stanford.edu/iiif/image-api/1.1/compliance.html#level1",
      "http://library.stanford.edu/iiif/image-api/1.1/conformance.html#level1",
      "http://library.stanford.edu/iiif/image-api/compliance.html#level2",
      "http://library.stanford.edu/iiif/image-api/1.1/compliance.html#level2",
      "http://library.stanford.edu/iiif/image-api/1.1/conformance.html#level2");

  @JsonProperty("@context")
  public static final String CONTEXT = "http://iiif.io/api/image/2/context.json";
  @JsonProperty("@type")
  public static final String TYPE = "iiif:ImageProfile";

  private Set<Format> formats;
  private Set<Quality> qualities;

  @JsonProperty("supports")
  Set<Feature> features;

  Long maxArea;
  Integer maxHeight;
  Integer maxWidth;

  public static ImageApiProfile fromUrl(String url) {
    URI uri = URI.create(url);
    if (uri.equals(LEVEL_ZERO.getIdentifier())) {
      return LEVEL_ZERO;
    } else if (uri.equals(LEVEL_ONE.getIdentifier())) {
      return LEVEL_ONE;
    } else if (uri.equals(LEVEL_TWO.getIdentifier())) {
      return LEVEL_TWO;
    } else if (V1_PROFILES.contains(url)) {
      String lvl = url.split("#level")[1];
      switch (lvl) {
        case "0":
          return new ImageApiProfile(url, LEVEL_ZERO);
        case "1":
          return new ImageApiProfile(url, LEVEL_ONE);
        case "2":
          return new ImageApiProfile(url, LEVEL_TWO);
        default:
          break;
      }
    }
    return new ImageApiProfile(url);
  }

  public ImageApiProfile() {
    super(null);
  }

  private ImageApiProfile(String url) {
    super(URI.create(url));
  }

  private ImageApiProfile(String url, ImageApiProfile profile) {
    this(url);
    this.setFormats(profile.getFormats());
    this.setQualities(profile.getQualities());
    this.setFeatures(profile.getFeatures());
    this.setMaxWidth(profile.getMaxWidth());
    this.setMaxHeight(profile.getMaxHeight());
    this.setMaxArea(profile.getMaxArea());
  }

  public Set<Format> getFormats() {
    return formats;
  }

  public void setFormats(Set<Format> formats) {
    this.formats = formats;
  }

  public ImageApiProfile addFormat(Format first, Format... rest) {
    if (this.formats == null) {
      this.formats = new LinkedHashSet<>();
    }
    this.formats.addAll(Lists.asList(first, rest));
    return this;
  }

  public Set<Quality> getQualities() {
    return qualities;
  }

  public void setQualities(Set<Quality> qualities) {
    this.qualities = qualities;
  }

  public ImageApiProfile addQuality(Quality first, Quality... rest) {
    if (this.qualities == null) {
      this.qualities = new LinkedHashSet<>();
    }
    this.qualities.addAll(Lists.asList(first, rest));
    return this;
  }

  public Set<Feature> getFeatures() {
    return features;
  }

  public void setFeatures(Set<Feature> features) {
    this.features = features;
  }

  public ImageApiProfile addFeature(Feature first, Feature... rest) {
    if (this.features == null) {
      this.features = new LinkedHashSet<>();
    }
    this.features.addAll(Lists.asList(first, rest));
    return this;
  }

  public Long getMaxArea() {
    return maxArea;
  }

  public void setMaxArea(Long maxArea) {
    this.maxArea = maxArea;
  }

  public Integer getMaxHeight() {
    return maxHeight;
  }

  public void setMaxHeight(Integer maxHeight) {
    this.maxHeight = maxHeight;
  }

  public Integer getMaxWidth() {
    return maxWidth;
  }

  public void setMaxWidth(Integer maxWidth) {
    this.maxWidth = maxWidth;
  }

  /**
   * Merge multiple profiles into one. Useful for image servers that want to consolidate the limits given in a info.json.
   *
   * @param profiles profiles to be merged
   * @return merged profile
   */
  public static ImageApiProfile merge(List<Profile> profiles) {
    return profiles.stream()
        .filter(ImageApiProfile.class::isInstance)
        .map(ImageApiProfile.class::cast)
        .reduce(new ImageApiProfile(), ImageApiProfile::merge);
  }

  /**
   * Merge two profiles.
   *
   * @param other profile to be merged
   * @return merged profile
   */
  public ImageApiProfile merge(ImageApiProfile other) {
    ImageApiProfile merged = new ImageApiProfile();
    streamNotNull(this.features).forEach(merged::addFeature);
    streamNotNull(other.features).forEach(merged::addFeature);
    streamNotNull(this.formats).forEach(merged::addFormat);
    streamNotNull(other.formats).forEach(merged::addFormat);
    streamNotNull(this.qualities).forEach(merged::addQuality);
    streamNotNull(other.qualities).forEach(merged::addQuality);
    if (this.maxWidth != null && other.maxWidth == null) {
      merged.maxWidth = this.maxWidth;
    } else if (this.maxWidth == null && other.maxWidth != null) {
      merged.maxWidth = other.maxWidth;
    } else if (this.maxWidth != null) {
      merged.maxWidth = Math.min(this.maxWidth, other.maxWidth);
    }
    if (this.maxHeight != null && other.maxHeight == null) {
      merged.maxHeight = this.maxHeight;
    } else if (this.maxHeight == null && other.maxHeight != null) {
      merged.maxHeight = other.maxHeight;
    } else if (this.maxHeight != null) {
      merged.maxHeight = Math.min(this.maxHeight, other.maxHeight);
    }
    if (this.maxArea != null && other.maxArea == null) {
      merged.maxArea = this.maxArea;
    } else if (this.maxArea == null && other.maxArea != null) {
      merged.maxArea = other.maxArea;
    } else if (this.maxArea != null) {
      merged.maxArea = Math.min(this.maxArea, other.maxArea);
    }
    return merged;
  }

  private <T> Stream<T> streamNotNull(Collection<T> src) {
    if (src != null) {
      return src.stream();
    } else {
      return Stream.empty();
    }
  }

  @Override
  public boolean equals(Object o) {
    if (!super.equals(o)) {
      return false;
    }

    ImageApiProfile that = (ImageApiProfile) o;
    if (formats != null ? !formats.equals(that.formats) : that.formats != null) {
      return false;
    }
    if (qualities != null ? !qualities.equals(that.qualities) : that.qualities != null) {
      return false;
    }
    return features != null ? features.equals(that.features) : that.features == null;
  }

  @Override
  public int hashCode() {
    int result = formats != null ? formats.hashCode() : 0;
    result = 31 * result + (qualities != null ? qualities.hashCode() : 0);
    result = 31 * result + (features != null ? features.hashCode() : 0);
    return result;
  }

  @Override
  public String toString() {
    if (this.getIdentifier() != null) {
      return this.getIdentifier().toString();
    } else {
      return super.toString();
    }
  }
}


package de.digitalcollections.iiif.model.image;

import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonValue;
import com.google.common.base.Objects;
import java.math.BigDecimal;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

public class RotationRequest {

  // Matches floating point values
  private static final Pattern PATTERN = Pattern.compile("^(!)?([0-9]*\\.?[0-9]+)$");
  BigDecimal rotation;
  boolean mirror = false;

  /**
   * Parse a rotation request from an IIIF Image API compliant rotation string.
   *
   * @param str iiif image api compliant rotation string
   * @return RotationRequest represented by th given string
   * @throws ResolvingException if the rotation string is malformed
   */
  @JsonCreator
  public static RotationRequest fromString(String str) throws ResolvingException {
    Matcher matcher = PATTERN.matcher(str);
    if (!matcher.matches()) {
      throw new ResolvingException("Bad format: " + str);
    }
    return new RotationRequest(
        new BigDecimal(matcher.group(2)),
        !(matcher.group(1) == null));
  }

  public RotationRequest(int rotation) throws ResolvingException {
    this(BigDecimal.valueOf(rotation), false);
  }

  /**
   * Create a new rotation request.
   *
   * @param rotation Rotation in degrees. Must be between 0 and 360
   * @param mirror Mirror the image when rotating
   * @throws ResolvingException if the rotation degrees are not between 0 and 360
   */
  public RotationRequest(BigDecimal rotation, boolean mirror) throws ResolvingException {
    if (rotation.floatValue() < 0 || rotation.floatValue() > 360) {
      throw new ResolvingException("Rotation must be between 0 and 360");
    }
    this.rotation = rotation;
    this.mirror = mirror;
  }

  public double getRotation() {
    return rotation.doubleValue();
  }

  public boolean isMirror() {
    return mirror;
  }

  /**
   * Create an IIIF Image API compatible rotation string.
   *
   * @return IIIF Image API compatible rotation string represented by this instance
   */
  @JsonValue
  @Override
  public String toString() {
    String out = this.rotation.toString();
    if (mirror) {
      out = "!" + out;
    }
    return out;
  }

  @Override
  public boolean equals(Object o) {
    if (this == o) {
      return true;
    }
    if (o == null || getClass() != o.getClass()) {
      return false;
    }
    RotationRequest that = (RotationRequest) o;
    return mirror == that.mirror
           && Objects.equal(rotation, that.rotation);
  }

  @Override
  public int hashCode() {
    return Objects.hashCode(rotation, mirror);
  }
}


package de.digitalcollections.iiif.model.jackson;

import com.fasterxml.jackson.databind.deser.std.StdDelegatingDeserializer;
import com.fasterxml.jackson.databind.module.SimpleModule;
import com.fasterxml.jackson.databind.ser.std.StdDelegatingSerializer;
import com.fasterxml.jackson.databind.util.Converter;
import com.fasterxml.jackson.databind.util.StdConverter;
import de.digitalcollections.model.api.identifiable.resource.MimeType;
import java.util.function.Function;

public class IiifModule extends SimpleModule {

  public IiifModule() {
    super("iiif-module");

    // This will set the ResourceSerializer. We can't set it directly, since it needs to be passed
    // the default serializer.
    this.setSerializerModifier(new SerializerModifier());
    this.setDeserializerModifier(new DeserializerModifier());

    // Just use MimeType's getTypeName and String constructor for serializing/deserializing it
    this.addSerializer(new StdDelegatingSerializer(MimeType.class, toString(MimeType::getTypeName)));
    this.addDeserializer(MimeType.class, new StdDelegatingDeserializer<>(fromString(MimeType::fromTypename)));
  }

  /** Helper function to create Converter from lambda **/
  private <T> Converter<String, T> fromString(Function<String, ? extends T> fun) {
    return new StdConverter<String, T>() {
      @Override
      public T convert(String value) {
        return fun.apply(value);
      }
    };
  }

  /** Helper function to create Converter from lambda **/
  private <T> Converter<T, String> toString(Function<T, String> fun) {
    return new StdConverter<T, String>() {
      @Override
      public String convert(T value) {
        return fun.apply(value);
      }
    };
  }
}


package de.digitalcollections.iiif.model.sharedcanvas;

import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.google.common.collect.ImmutableSet;
import com.google.common.collect.Lists;
import de.digitalcollections.iiif.model.PropertyValue;
import de.digitalcollections.iiif.model.enums.ViewingHint.Type;
import de.digitalcollections.iiif.model.interfaces.PageContainer;
import de.digitalcollections.iiif.model.interfaces.Pageable;
import java.net.URI;
import java.time.OffsetDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.Set;

import static java.util.Arrays.stream;

/**
 * An ordered list of manifests, and/or further collections.
 *
 * Collections allow easy advertising and browsing of the manifests in a hierarchical structure, potentially with its
 * own descriptive information. They can also provide clients with a means to locate all of the manifests known to the
 * publishing institution.
 *
 * See http://iiif.io/api/presentation/2.1/#collection
 */
public class Collection extends Resource<Collection> implements Pageable<Collection>, PageContainer<Collection> {

  public static final String TYPE = "sc:Collection";

  private OffsetDateTime navDate;

  @JsonProperty("first")
  private Collection firstPage;

  @JsonProperty("last")
  private Collection lastPage;

  @JsonProperty("total")
  private Integer totalPages;

  @JsonProperty("next")
  private Collection nextPage;

  @JsonProperty("previous")
  private Collection previousPage;

  @JsonProperty("startIndex")
  private Integer startIndex;

  private List<Collection> collections;
  private List<Manifest> manifests;
  private List<Resource> members;

  @JsonCreator
  public Collection(@JsonProperty("@id") String identifier) {
    super(identifier);
  }

  public Collection(String identifier, String label) {
    super(identifier);
    this.addLabel(label);
  }

  public Collection(URI identifier, PropertyValue label) {
    this.setIdentifier(identifier);
    this.setLabel(label);
  }

  @Override
  public String getType() {
    return TYPE;
  }

  @JsonIgnore
  @Override
  public Set<Type> getSupportedViewingHintTypes() {
    return ImmutableSet.of(Type.TOP, Type.INDIVIDUALS, Type.MULTI_PART);
  }

  public List<Collection> getCollections() {
    return collections;
  }

  public void setCollections(List<Collection> collections) {
    this.collections = collections;
  }

  public Collection addCollection(Collection first, Collection... rest) {
    if (this.collections == null) {
      this.collections = new ArrayList<>();
    }
    this.collections.addAll(Lists.asList(first, rest));
    return this;
  }

  public List<Manifest> getManifests() {
    return manifests;
  }

  public void setManifests(List<Manifest> manifests) {
    this.manifests = manifests;
  }

  public Collection addManifest(Manifest first, Manifest... rest) {
    if (this.manifests == null) {
      this.manifests = new ArrayList<>();
    }
    this.manifests.addAll(Lists.asList(first, rest));
    return this;
  }

  public List<Resource> getMembers() {
    return members;
  }

  private void checkMember(Resource member) {
    if (member instanceof Collection) {
      Collection coll = (Collection) member;
      if (coll.getViewingHints().isEmpty()) {
        throw new IllegalArgumentException("Collection members must have a viewingHint.");
      }
    } else if (!(member instanceof Manifest)) {
      throw new IllegalArgumentException("Members must be either Manifest or Collection resources.");
    }
  }

  /**
   * Set the list of member resources.
   * Must be either instances of {@link Manifest} or {@link Collection}.
   * All {@link Collection} members must have at least one {@link de.digitalcollections.iiif.model.enums.ViewingHint}.
   *
   * @param members member resources
   * @throws IllegalArgumentException if at least one member is not a {@link Manifest} or {@link Collection} or is a
   *         {@link Collection} and does not have at least one {@link de.digitalcollections.iiif.model.enums.ViewingHint}
   */
  public void setMembers(List<Resource> members) {
    members.forEach(this::checkMember);
    this.members = members;
  }

  /**
   * Adds one or more member resources.
   * Must be either instances of {@link Manifest} or {@link Collection}.
   * All {@link Collection} members must have at least one {@link de.digitalcollections.iiif.model.enums.ViewingHint}.
   *
   * @param first first member
   * @param rest other members
   * @return this collection with added members
   * @throws IllegalArgumentException if at least one member is not a {@link Manifest} or {@link Collection} or is a
   *         {@link Collection} and does not have at least one {@link de.digitalcollections.iiif.model.enums.ViewingHint}
   */
  public Collection addMember(Resource first, Resource... rest) {
    if (this.members == null) {
      this.members = new ArrayList<>();
      checkMember(first);
      stream(rest).forEach(this::checkMember);
    }
    this.members.addAll(Lists.asList(first, rest));
    return this;
  }

  public OffsetDateTime getNavDate() {
    return navDate;
  }

  public void setNavDate(OffsetDateTime navDate) {
    this.navDate = navDate;
  }

  @Override
  public Collection getFirst() {
    return firstPage;
  }

  @Override
  public void setFirst(Collection first) {
    this.firstPage = first;
  }

  @Override
  public Collection getLast() {
    return this.lastPage;
  }

  @Override
  public void setLast(Collection last) {
    this.lastPage = last;

  }

  @Override
  public Integer getTotal() {
    return totalPages;
  }

  @Override
  public void setTotal(int total) {
    this.totalPages = total;
  }

  @Override
  public Collection getNext() {
    return nextPage;
  }

  @Override
  public void setNext(Collection next) {
    this.nextPage = next;
  }

  @Override
  public Collection getPrevious() {
    return this.previousPage;
  }

  @Override
  public void setPrevious(Collection previous) {
    this.previousPage = previous;
  }

  @Override
  public Integer getStartIndex() {
    return startIndex;
  }

  @Override
  public void setStartIndex(int startIndex) {
    this.startIndex = startIndex;
  }

}


package de.digitalcollections.iiif.model.sharedcanvas;

import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.google.common.collect.ImmutableSet;
import com.google.common.collect.Lists;
import de.digitalcollections.iiif.model.ModelUtilities;
import de.digitalcollections.iiif.model.ModelUtilities.Completeness;
import de.digitalcollections.iiif.model.enums.ViewingDirection;
import de.digitalcollections.iiif.model.enums.ViewingHint.Type;
import java.net.URI;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Set;

import static java.util.Arrays.stream;

/**
 * An ordered list of canvases, and/or further ranges.
 *
 * Ranges allow canvases, or parts thereof, to be grouped together in some way. This could be for textual reasons, such
 * as to distinguish books, chapters, verses, sections, non-content-bearing pages, the table of contents or similar.
 * Equally, physical features might be important such as quires or gatherings, sections that have been added later and
 * so forth.
 *
 * See http://iiif.io/api/presentation/2.1/#range
 */
public class Range extends Resource<Range> {

  public static final String TYPE = "sc:Range";

  private ViewingDirection viewingDirection;
  private URI startCanvas;
  private Layer contentLayer;

  List<Range> ranges;
  List<Canvas> canvases;
  List<Resource> members;

  @JsonCreator
  public Range(@JsonProperty("@id") String identifier) {
    super(identifier);
  }

  public Range(String identifier, String label) {
    this(identifier);
    this.addLabel(label);
  }

  @Override
  public String getType() {
    return TYPE;
  }

  public ViewingDirection getViewingDirection() {
    return viewingDirection;
  }

  public void setViewingDirection(ViewingDirection viewingDirection) {
    this.viewingDirection = viewingDirection;
  }

  @JsonIgnore
  @Override
  public Set<Type> getSupportedViewingHintTypes() {
    return ImmutableSet.of(Type.INDIVIDUALS, Type.PAGED, Type.CONTINUOUS, Type.TOP);
  }

  public URI getStartCanvas() {
    return startCanvas;
  }

  public void setStartCanvas(URI startCanvas) {
    this.startCanvas = startCanvas;
  }

  public Layer getContentLayer() {
    return contentLayer;
  }

  public void setContentLayer(Layer contentLayer) {
    this.contentLayer = contentLayer;
  }

  public void setContentLayer(String identifier) {
    this.setContentLayer(new Layer(identifier));
  }

  public List<Canvas> getCanvases() {
    return canvases;
  }

  private void checkIdOnly(Resource res) {
    Completeness completeness = ModelUtilities.getCompleteness(res, res.getClass());
    if (completeness != Completeness.ID_AND_TYPE && completeness != Completeness.ID_ONLY) {
      throw new IllegalArgumentException(
          "Member resource must only have an identifier and no other field."
        + " Use add<Resource>(URI first, URI... rest) for convenience.");
    }
  }

  public void setCanvases(List<Canvas> canvases) {
    canvases.forEach(this::checkIdOnly);
    this.canvases = canvases;
  }

  public Range addCanvas(Canvas first, Canvas... rest) {
    if (this.canvases == null) {
      this.canvases = new ArrayList<>();
    }
    checkIdOnly(first);
    stream(rest).forEach(this::checkIdOnly);
    this.canvases.addAll(Lists.asList(first, rest));
    return this;
  }

  public Range addCanvas(String idOfFirst, String... idsOfRest) {
    return this.addCanvas(new Canvas(idOfFirst),
                          Arrays.stream(idsOfRest).map(Canvas::new).toArray(Canvas[]::new));
  }

  public List<Range> getRanges() {
    return ranges;
  }

  public void setRanges(List<Range> ranges) {
    ranges.forEach(this::checkIdOnly);
    this.ranges = ranges;
  }

  public Range addRange(Range first, Range... rest) {
    if (this.ranges == null) {
      this.ranges = new ArrayList<>();
    }
    checkIdOnly(first);
    stream(rest).forEach(this::checkIdOnly);
    this.ranges.addAll(Lists.asList(first, rest));
    return this;
  }

  public Range addRange(String first, String... rest) {
    return this.addRange(new Range(first),
                         Arrays.stream(rest).map(Range::new).toArray(Range[]::new));
  }

  public List<Resource> getMembers() {
    return members;
  }

  private void checkMember(Resource res) {
    if (!(res instanceof Range) && !(res instanceof Canvas)) {
      throw new IllegalArgumentException("Member resources must be either of type Range or Canvas");
    }
    if (res.getIdentifier() == null || res.getLabel() == null || res.getLabel().getValues().isEmpty()) {
      throw new IllegalArgumentException("Member resources must have identifier, type and label set.");
    }
  }

  /**
   * Sets the member resources.
   * Must be either instances of {@link Range} or {@link Canvas}.
   * All members must have an identifier and a label.
   *
   * @param members member resources
   * @throws IllegalArgumentException if at least one member is not a {@link Range} or {@link Canvas} or does not have
   *         an identifier and a label;
   */
  public void setMembers(List<Resource> members) {
    members.forEach(this::checkMember);
    this.members = members;
  }

  /**
   * Adds one or more member resources.
   * Must be either instances of {@link Range} or {@link Canvas}.
   * All members must have an identifier and a label.
   *
   * @param first first member
   * @param rest other members
   * @return this instance with added members
   * @throws IllegalArgumentException if at least one member is not a {@link Range} or {@link Canvas} or does not have
   *         an identifier and a label;
   */
  public Range addMember(Resource first, Resource... rest) throws IllegalArgumentException {
    if (this.members == null) {
      this.members = new ArrayList<>();
    }
    List<Resource> membersToAdd = Lists.asList(first, rest);
    membersToAdd.forEach(this::checkMember);
    this.members.addAll(membersToAdd);
    return this;
  }
}


package de.digitalcollections.iiif.model;

import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
import com.google.common.collect.Lists;
import de.digitalcollections.iiif.model.jackson.serialization.PropertyValueDeserializer;
import de.digitalcollections.iiif.model.jackson.serialization.PropertyValueSerializer;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Map.Entry;
import java.util.Objects;
import java.util.Set;

import static com.google.common.base.Preconditions.checkArgument;
import static com.google.common.base.Preconditions.checkNotNull;

/**
 * Type for strings that are intended to be displayed to the user.
 *
 * Is organized as a mapping of languages to one or more values.
 * See http://iiif.io/api/presentation/2.1/#language-of-property-values and
 * http://iiif.io/api/presentation/2.1/#html-markup-in-property-values for more information.
 */
@JsonSerialize(using = PropertyValueSerializer.class)
@JsonDeserialize(using = PropertyValueDeserializer.class)
public class PropertyValue {

  private Map<Locale, List<String>> localizations = new LinkedHashMap<>();

  public PropertyValue() {
  }

  public PropertyValue(String first, String... rest) {
    this(Locale.ROOT, first, rest);
  }

  public PropertyValue(Locale language, String first, String... rest) {
    setValues(language, first, rest);
  }

  public void setValues(String first, String... rest) {
    this.setValues(Locale.ROOT, first, rest);
  }

  public void setValues(Locale language, String firstValue, String... rest) {
    checkNotNull(language);
    checkNotNull(firstValue);
    checkArgument(Arrays.stream(rest).allMatch(Objects::nonNull));
    // Need to wrap it with an ArrayList since we might want to add new values later on
    this.localizations.put(language, new ArrayList<>(Lists.asList(firstValue, rest)));
  }

  public PropertyValue addValue(String first, String... rest) {
    return addValue(Locale.ROOT, first, rest);
  }

  public PropertyValue addValue(Locale language, String first, String... rest) {
    if (this.localizations.containsKey(language)) {
      this.localizations.get(language).addAll(Lists.asList(first, rest));
    } else {
      setValues(language, first, rest);
    }
    return this;
  }

  public Set<Locale> getLocalizations() {
    return this.localizations.keySet();
  }

  public List<String> getValues() {
    Locale defaultLang = Locale.getDefault();
    if (localizations.containsKey(defaultLang)) {
      return localizations.get(defaultLang);
    } else if (localizations.containsKey(Locale.ROOT)) {
      return localizations.get(Locale.ROOT);
    } else if (localizations.size() > 0) {
      return localizations.entrySet().iterator().next().getValue();
    } else {
      return Collections.emptyList();
    }
  }

  public List<String> getValues(Locale locale) {
    return localizations.get(locale);
  }

  public String getFirstValue() {
    if (getValues() == null) {
      return null;
    }
    return getValues().get(0);
  }

  /*
   * see http://iiif.io/api/presentation/2.1/#language-of-property-values
   * example: {"description": {"@value": "Here is a longer description of the object", "@language": "en"}}
   *
   * In the case where multiple values are supplied, clients must use the following algorithm to determine
   * which values to display to the user.
   *
   *

   *
If none of the values have a language associated with them, the client must display all of the values.

   *
Else, the client should try to determine the user’s language preferences, or failing that use some
   * default language preferences. Then:

   *

   *
If any of the values have a language associated with them, the client must display all of the values
   *     associated with the language that best matches the language preference.

   *
If all of the values have a language associated with them, and none match the language preference,
   *     the client must select a language and display all of the values associated with that language.

   *
If some of the values have a language associated with them, but none match the language preference,
   *     the client must display all of the values that do not have a language associated with them.

   *

   *

   */
  public String getFirstValue(Locale locale) {
    List<String> values = getValues(locale);
    if (values == null) {
      return getFirstValue();
    } else {
      return values.get(0);
    }
  }

  @Override
  public String toString() {
    StringBuilder out = new StringBuilder();
    for (Entry<Locale, List<String>> e : this.localizations.entrySet()) {
      String language = e.getKey().toLanguageTag();
      String values;
      if (e.getValue().size() == 1) {
        values = e.getValue().get(0);
      } else {
        values = "{" + String.join(", ", e.getValue()) + "}";
      }
      out.append(String.format("%s=%s", language, values));
    }
    return String.format("PropertyValue(%s)", out.toString());
  }
}


package de.digitalcollections.iiif.model.image;

import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonValue;
import com.google.common.base.Objects;
import java.awt.Dimension;
import java.awt.Rectangle;
import java.math.BigDecimal;
import java.util.Collections;
import java.util.Comparator;
import java.util.List;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

public class SizeRequest {

  private static final Pattern PARSE_PAT = Pattern.compile("^(!|pct:)?(?:([0-9]+)?,([0-9]+)?|([0-9.]+))$");
  private boolean max = false;
  private boolean bestFit = false;
  private Integer width = null;
  private Integer height = null;
  private BigDecimal percentage = null;

  /**
   * Parse an IIIF Image API compliant size request string
   *
   * @param str IIIF Image API compliant size request string
   * @return parsed SizeRequest
   * @throws ResolvingException if the request string is malformed
   */
  @JsonCreator
  public static SizeRequest fromString(String str) throws ResolvingException {
    if (str.equals("full")) {
      return new SizeRequest();
    }
    if (str.equals("max")) {
      return new SizeRequest(true);
    }
    Matcher matcher = PARSE_PAT.matcher(str);
    if (!matcher.matches()) {
      throw new ResolvingException("Bad format: " + str);
    }
    if (matcher.group(1) != null) {
      if (matcher.group(1).equals("!")) {
        return new SizeRequest(
            Integer.valueOf(matcher.group(2)),
            Integer.valueOf(matcher.group(3)),
            true);
      } else if (matcher.group(1).equals("pct:")) {
        return new SizeRequest(new BigDecimal(matcher.group(4)));
      }
    }
    Integer width = null;
    Integer height = null;
    if (matcher.group(2) != null) {
      width = Integer.parseInt(matcher.group(2));
    }
    if (matcher.group(3) != null) {
      height = Integer.parseInt(matcher.group(3));
    }
    return new SizeRequest(width, height);
  }

  /**
   * Create a size request for the full native resolution of the image region.
   */
  public SizeRequest() {
    this(false);
  }

  /**
   * Create a size request for the maximum supported size of the image region, if isMax is true.
   * If isMax is false, it behaves identically to the default constructor.
   *
   * @param isMax true causes a size request for the maximum supported size of the image region
   */
  public SizeRequest(boolean isMax) {
    this.max = isMax;
  }

  /**
   * Create a size request for a given width or height.
   * One of both can be null (the other value will be determined based on the aspect ratio of the image region), but not both at once.
   *
   * @param width width of size request
   * @param height height of size request
   * @throws ResolvingException if neither width nor height are specified
   */
  public SizeRequest(Integer width, Integer height) throws ResolvingException {
    if (width == null && height == null) {
      throw new ResolvingException("Either width or height must be specified!");
    }
    this.width = width;
    this.height = height;
  }

  /**
   * Create a size request for a given width and height and signal that the server can decide to render smaller
   * resolutions as it deems neccessary.
   * @param width width of size request
   * @param height height of size request
   * @param bestFit true, if server can decide to render smaller resolutions as it deems neccessary
   * @throws de.digitalcollections.iiif.model.image.ResolvingException if params can not be resolved to Size Request
   */
  public SizeRequest(int width, int height, boolean bestFit) throws ResolvingException {
    this(width, height);
    this.bestFit = bestFit;
  }

  /**
   * Create a size request that scaled both dimensions according to a fixed percentage, maintaining the aspect ratio.
   *
   * @param percentage scaling percentage, maintaining aspect ratio
   * @throws ResolvingException if the percentage is not between 0 and 100
   */
  public SizeRequest(BigDecimal percentage) throws ResolvingException {
    if (percentage.doubleValue() < 0 || percentage.doubleValue() > 100) {
      throw new ResolvingException("Percentage must be between 0 and 100!");
    }
    this.percentage = percentage;
  }

  /**
   * Return whether the maximum resolution was requested.
   *
   * @return true, if maximum resolution was requested
   */
  public boolean isMax() {
    return max;
  }

  /**
   * Return whether the server can decide to render smaller resolutions than desired.
   *
   * @return true, if the server can decide to render smaller resolutions than desired
   */
  public boolean isBestFit() {
    return bestFit;
  }

  /**
   * Get the requested width
   *
   * @return requested width
   */
  public Integer getWidth() {
    return width;
  }

  /**
   * Get the requested height
   *
   * @return requested height
   */
  public Integer getHeight() {
    return height;
  }

  /**
   * Get the requested percentage to be used for scaling
   *
   * @return requested percentage to be used for scaling
   */
  public BigDecimal getPercentage() {
    return percentage;
  }

  /**
   * Get the canonical form of this request.
   * @see IIIF Image API specification
   *
   * @param nativeSize native size of request
   * @param profile image api profile
   * @return canonical form of this request
   * @throws de.digitalcollections.iiif.model.image.ResolvingException if nativeSize can not be converted to canonical form
   */
  public String getCanonicalForm(Dimension nativeSize, ImageApiProfile profile) throws ResolvingException {
    Dimension resolved = this.resolve(nativeSize, profile);
    // "w," requests are already canonical
    double nativeRatio = nativeSize.getWidth() / nativeSize.getHeight();
    double resolvedRatio = resolved.getWidth() / resolved.getHeight();
    if (resolved.equals(nativeSize)) {
      return "full";
    } else if (this.width != null && this.height == null) {
      return this.toString();
    } else if (Math.floor(resolvedRatio * nativeSize.getHeight()) == nativeSize.getWidth()
               || Math.ceil(resolvedRatio * nativeSize.getHeight()) == nativeSize.getWidth()) {
      return String.format("%d,", resolved.width);
    } else {
      return String.format("%d,%d", resolved.width, resolved.height);
    }
  }

  public Dimension resolve(Dimension nativeSize, ImageApiProfile profile) throws ResolvingException {
    return resolve(nativeSize, Collections.emptyList(), profile);
  }

  /**
   * Resolve the request to dimensions that can be used for scaling, based on the native size of the image region and the available profile.
   *
   * @param nativeSize native size of the image region
   * @param availableSizes available sizes
   * @param profile image api profile
   * @return resolved dimension
   * @throws de.digitalcollections.iiif.model.image.ResolvingException if params can not be resolved to Dimension
   */
  public Dimension resolve(Dimension nativeSize, List<Dimension> availableSizes, ImageApiProfile profile) throws ResolvingException {
    double aspect = (double) nativeSize.width / (double) nativeSize.height;
    // "max"
    if (max) {
      // By default, identical to the largest available size or the native size if no sizes were specified
      Dimension dim = availableSizes.stream()
          // Avoid upscaling when dealing with region requests
          .filter(s -> s.width <= nativeSize.width && s.height <= nativeSize.height)
          // Select the largest available size
          .max(Comparator.comparing(Dimension::getWidth).thenComparing(Dimension::getHeight))
          // Otherwise, fall back to the native size
          .orElse(new Dimension(nativeSize.width, nativeSize.height));
      if (profile != null && profile.maxWidth != null) {
        if (dim.width > profile.maxWidth) {
          // If maximum width is set, width cannot exceed it
          dim.width = profile.maxWidth;
          dim.height = (int) (profile.maxWidth / aspect);
        }
        int maxHeight = profile.maxHeight != null ? profile.maxHeight : profile.maxWidth;
        if (dim.height > maxHeight) {
          // Adjust height if it exceeds maximum height
          dim.height = maxHeight;
          dim.width = (int) (aspect * dim.height);
        }
      }
      if (profile != null && profile.maxArea != null) {
        // Fit width and height into the maximum available area, preserving the aspect ratio
        long currentArea = (long) dim.width * (long) dim.height;
        if (currentArea > profile.maxArea) {
          dim.width = (int) Math.sqrt(aspect * (double) profile.maxArea);
          dim.height = (int) (dim.width / aspect);
          if (dim.width <= 0 || dim.height <= 0) {
            throw new ResolvingException(String.format(
                "Cannot fit image with dimensions %dx%d into maximum area of %d pixels.",
                nativeSize.width, nativeSize.height, profile.maxArea));
          }
        }
      }
      return dim;
    }
    Dimension out;
    if (percentage != null || bestFit) {  // "pct:"
      double ratio;
      if (percentage != null) {
        ratio = percentage.doubleValue() / 100.0;
      } else {
        ratio = Math.min(width / nativeSize.getWidth(), height / nativeSize.getHeight());
      }
      out = new Dimension((int) (ratio * nativeSize.width), (int) (ratio * nativeSize.height));
    } else if (width == null && height == null) {  // "full"
      out = nativeSize;
    } else {
      out = new Dimension();
      if (width != null) {
        out.width = width;
      }
      if (height != null) {
        out.height = height;
      }
      if (width == null) {  // ",h"
        out.width = (int) (out.height * aspect);
      }
      if (height == null) { // "w,"
        out.height = (int) (out.width / aspect);
      }
    }
    Integer maxHeight = profile.maxHeight != null ? profile.maxHeight : profile.maxWidth;
    if (profile.maxWidth != null && out.width > profile.maxWidth) {
      throw new ResolvingException(String.format(
          "Requested width (%d) exceeds maximum width (%d) as specified in the profile.", out.width, profile.maxWidth));
    } else if (maxHeight != null && out.height > maxHeight) {
      throw new ResolvingException(String.format(
          "Requested height (%d) exceeds maximum height (%d) as specified in the profile.", out.height, maxHeight));
    } else if (profile.maxArea != null && out.height * out.width > profile.maxArea) {
      throw new ResolvingException(String.format(
          "Requested area (%d*%d = %d) exceeds maximum area (%d) as specified in the profile",
          out.width, out.height, out.width * out.height, profile.maxArea));
    } else if ((profile.features == null || !profile.features.contains(ImageApiProfile.Feature.SIZE_ABOVE_FULL))
               && (out.width > nativeSize.width || out.height > nativeSize.height)) {
      throw new ResolvingException(String.format(
          "Requested dimensions (%dx%d) exceed native dimensions (%dx%d), profile states that upscaling is not supported.",
          out.width, out.height, nativeSize.width, nativeSize.height));
    }
    return out;
  }

  /**
   * Like {@link #resolve(Dimension, ImageApiProfile)}, but can be used with a {@link Rectangle}, e.g. as returned from {@link RegionRequest#resolve(Dimension)}.
   *
   * @param region image region
   * @param profile image api profile
   * @return resolved size dimension
   * @throws de.digitalcollections.iiif.model.image.ResolvingException if rectangle region can not be resolved
   */
  public Dimension resolve(Rectangle region, ImageApiProfile profile) throws ResolvingException {
    return resolve(
        new Dimension(region.width, region.height),
        profile);
  }

  /**
   * Create an IIIF Image API compliant size request string
   *
   * @return String representation of size request
   */
  @JsonCreator
  @JsonValue
  @Override
  public String toString() {
    if (width == null && height == null && percentage == null) {
      return max ? "max" : "full";
    }
    if (percentage != null) {
      return String.format("pct:%s", percentage);
    }
    StringBuilder rv = new StringBuilder();
    if (width != null) {
      rv.append(String.valueOf(width));
    }
    rv.append(",");
    if (height != null) {
      rv.append(String.valueOf(height));
    }
    return bestFit ? "!" + rv.toString() : rv.toString();
  }

  @Override
  public boolean equals(Object o) {
    if (this == o) {
      return true;
    }
    if (o == null || getClass() != o.getClass()) {
      return false;
    }
    SizeRequest that = (SizeRequest) o;
    return max == that.max
        && bestFit == that.bestFit
        && Objects.equal(width, that.width)
        && Objects.equal(height, that.height)
        && Objects.equal(percentage, that.percentage);
  }

  @Override
  public int hashCode() {
    return Objects.hashCode(max, bestFit, width, height, percentage);
  }
}


package com.github.cosysoft.device.android.impl;

import com.android.ddmlib.AndroidDebugBridge;
import com.android.ddmlib.DdmPreferences;
import com.android.ddmlib.IDevice;
import com.github.cosysoft.device.DeviceStore;
import com.github.cosysoft.device.android.AndroidDevice;
import com.github.cosysoft.device.exception.AndroidDeviceException;
import com.github.cosysoft.device.exception.DeviceNotFoundException;
import com.github.cosysoft.device.exception.NestedException;
import com.github.cosysoft.device.shell.AndroidSdk;
import java.util.HashMap;
import java.util.Map;
import java.util.TreeSet;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

public class AndroidDeviceStore implements DeviceStore {

  protected static final Logger logger = LoggerFactory
      .getLogger(AndroidDeviceStore.class);

  private Map<IDevice, AndroidDevice> connectedDevices = new HashMap<IDevice, AndroidDevice>();

  private AndroidDebugBridge bridge;
  private boolean shouldKeepAdbAlive = false;

  static class DeviceStoreHolder {

    static final AndroidDeviceStore instance = init();

    static AndroidDeviceStore init() {
      AndroidDeviceStore instance;
      instance = new AndroidDeviceStore();
      instance.initAndroidDevices(false);
      return instance;
    }
  }

  public static AndroidDeviceStore getInstance() {
    return DeviceStoreHolder.instance;
  }

  /**
   * call once
   */
  public void initAndroidDevices(boolean shouldKeepAdbAlive)
      throws AndroidDeviceException {
    //        DdmPreferences.setLogLevel(LogLevel.VERBOSE.getStringValue());
    DdmPreferences.setInitialThreadUpdate(true);
    DdmPreferences.setInitialHeapUpdate(true);
    this.initializeAdbConnection();
  }

  /**
   * Initializes the AndroidDebugBridge and registers the DefaultHardwareDeviceManager with the
   * AndroidDebugBridge device change listener.
   */
  protected void initializeAdbConnection() {
    // Get a device bridge instance. Initialize, create and restart.
    try {
      AndroidDebugBridge.init(true);
    } catch (IllegalStateException e) {
      // When we keep the adb connection alive the AndroidDebugBridge may
      // have been already
      // initialized at this point and it generates an exception. Do not
      // print it.
      if (!shouldKeepAdbAlive) {
        logger.error(
            "The IllegalStateException is not a show "
                + "stopper. It has been handled. This is just debug spew. Please proceed.",
            e);
        throw new NestedException("ADB init failed", e);
      }
    }

    bridge = AndroidDebugBridge.getBridge();

    if (bridge == null) {
      bridge = AndroidDebugBridge.createBridge(AndroidSdk.adb()
          .getAbsolutePath(), false);
    }

    long timeout = System.currentTimeMillis() + 60000;
    while (!bridge.hasInitialDeviceList()
        && System.currentTimeMillis() < timeout) {
      try {
        Thread.sleep(50);
      } catch (InterruptedException e) {
        throw new RuntimeException(e);
      }
    }
    // Add the existing devices to the list of devices we are tracking.
    IDevice[] devices = bridge.getDevices();
    logger.info("initialDeviceList size {}", devices.length);
    for (int i = 0; i < devices.length; i++) {
      logger.info("devices state: {},{} ", devices[i].getName(),
          devices[i].getState());
      connectedDevices.put(devices[i], new DefaultHardwareDevice(
          devices[i]));
    }

    bridge.addDeviceChangeListener(new DeviceChangeListener(connectedDevices));
  }

  @Override
  public TreeSet<AndroidDevice> getDevices() {
    return new TreeSet<AndroidDevice>(connectedDevices.values());
  }

  @Override
  public AndroidDevice getDeviceBySerial(String serialID) {
    for (AndroidDevice device : getDevices()) {
      if (device.getSerialNumber().equalsIgnoreCase(serialID)) {
        return device;
      }
    }
    throw new DeviceNotFoundException(String.format("Device %s not found", serialID));
  }

  /**
   * Shutdown the AndroidDebugBridge and clean up all connected devices.
   */
  @Override
  public void shutdown() {
    if (!shouldKeepAdbAlive) {
      AndroidDebugBridge.disconnectBridge();
      AndroidDebugBridge.terminate();
    }
    logger.info("stopping Device Manager");
  }

  /**
   * used with caution or don't call this method
   */
  @Override
  public void shutdownForcely() {
    AndroidDebugBridge.disconnectBridge();
    AndroidDebugBridge.terminate();
  }
}


package com.github.cosysoft.device.exception;

/**
 *
 * @author ltyao
 *
 */
public class NestedException extends RuntimeException {

	private static final long serialVersionUID = 5439915454935047936L;

	static {
		// Eagerly load the NestedExceptionUtils class to avoid classloader
		// deadlock
		// issues on OSGi when calling getMessage(). Reported by Don Brown;
		// SPR-5607.
		NestedExceptionUtils.class.getName();
	}

	/**
	 * Construct a {@code NestedRuntimeException} with the specified detail
	 * message.
	 *
	 * @param msg
	 *            the detail message
	 */
	public NestedException(String msg) {
		super(msg);
	}

	/**
	 * Construct a {@code NestedRuntimeException} with the specified detail
	 * message and nested exception.
	 *
	 * @param msg
	 *            the detail message
	 * @param cause
	 *            the nested exception
	 */
	public NestedException(String msg, Throwable cause) {
		super(msg, cause);
	}

	public NestedException(Throwable cause) {
		super(cause);
	}

	/**
	 * Return the detail message, including the message from the nested
	 * exception if there is one.
	 */
	@Override
	public String getMessage() {
		return NestedExceptionUtils
				.buildMessage(super.getMessage(), getCause());
	}

	/**
	 * Retrieve the innermost cause of this exception, if any.
	 *
	 * @return the innermost exception, or {@code null} if none
	 * @since 2.0
	 */
	public Throwable getRootCause() {
		Throwable rootCause = null;
		Throwable cause = getCause();
		while (cause != null && cause != rootCause) {
			rootCause = cause;
			cause = cause.getCause();
		}
		return rootCause;
	}

	/**
	 * Retrieve the most specific cause of this exception, that is, either the
	 * innermost cause (root cause) or this exception itself.
	 * <p>
	 * Differs from {@link #getRootCause()} in that it falls back to the present
	 * exception if there is no root cause.
	 *
	 * @return the most specific cause (never {@code null})
	 * @since 2.0.3
	 */
	public Throwable getMostSpecificCause() {
		Throwable rootCause = getRootCause();
		return (rootCause != null ? rootCause : this);
	}

	/**
	 * Check whether this exception contains an exception of the given type:
	 * either it is of the given class itself or it contains a nested cause of
	 * the given type.
	 *
	 * @param exType
	 *            the exception type to look for
	 * @return whether there is a nested exception of the specified type
	 */
	@SuppressWarnings("rawtypes")
	public boolean contains(Class exType) {
		if (exType == null) {
			return false;
		}
		if (exType.isInstance(this)) {
			return true;
		}
		Throwable cause = getCause();
		if (cause == this) {
			return false;
		}
		if (cause instanceof NestedException) {
			return ((NestedException) cause).contains(exType);
		} else {
			while (cause != null) {
				if (exType.isInstance(cause)) {
					return true;
				}
				if (cause.getCause() == cause) {
					break;
				}
				cause = cause.getCause();
			}
			return false;
		}
	}

}