/*
 * Copyright (C) 2013 The Android Open Source Project
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package com.android.camera.util;

import com.adobe.xmp.XMPException;
import com.adobe.xmp.XMPMeta;
import com.adobe.xmp.XMPMetaFactory;
import com.adobe.xmp.options.SerializeOptions;
import com.android.camera.debug.Log;

import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.io.UnsupportedEncodingException;
import java.util.ArrayList;
import java.util.List;

/**
 * Util class to read/write xmp from a jpeg image file. It only supports jpeg
 * image format, and doesn't support extended xmp now.
 * To use it:
 * XMPMeta xmpMeta = XmpUtil.extractOrCreateXMPMeta(filename);
 * xmpMeta.setProperty(PanoConstants.GOOGLE_PANO_NAMESPACE, "property_name", "value");
 * XmpUtil.writeXMPMeta(filename, xmpMeta);
 *
 * Or if you don't care the existing XMP meta data in image file:
 * XMPMeta xmpMeta = XmpUtil.createXMPMeta();
 * xmpMeta.setPropertyBoolean(PanoConstants.GOOGLE_PANO_NAMESPACE, "bool_property_name", "true");
 * XmpUtil.writeXMPMeta(filename, xmpMeta);
 */
public class XmpUtil {
  private static final Log.Tag TAG = new Log.Tag("XmpUtil");
  private static final int XMP_HEADER_SIZE = 29;
  private static final String XMP_HEADER = "http://ns.adobe.com/xap/1.0/\0";
  private static final int MAX_XMP_BUFFER_SIZE = 65502;

  private static final String GOOGLE_PANO_NAMESPACE = "http://ns.google.com/photos/1.0/panorama/";
  private static final String PANO_PREFIX = "GPano";

  private static final int M_SOI = 0xd8; // File start marker.
  private static final int M_APP1 = 0xe1; // Marker for Exif or XMP.
  private static final int M_SOS = 0xda; // Image data marker.

  // Jpeg file is composed of many sections and image data. This class is used
  // to hold the section data from image file.
  private static class Section {
    public int marker;
    public int length;
    public byte[] data;
  }

  static {
    try {
      XMPMetaFactory.getSchemaRegistry().registerNamespace(
          GOOGLE_PANO_NAMESPACE, PANO_PREFIX);
    } catch (XMPException e) {
      e.printStackTrace();
    }
  }

  /**
   * Extracts XMPMeta from JPEG image file.
   *
   * @param filename JPEG image file name.
   * @return Extracted XMPMeta or null.
   */
  public static XMPMeta extractXMPMeta(String filename) {
    if (!filename.toLowerCase().endsWith(".jpg")
        && !filename.toLowerCase().endsWith(".jpeg")) {
      Log.d(TAG, "XMP parse: only jpeg file is supported");
      return null;
    }

    try {
      return extractXMPMeta(new FileInputStream(filename));
    } catch (FileNotFoundException e) {
      Log.e(TAG, "Could not read file: " + filename, e);
      return null;
    }
  }

  /**
   *  Extracts XMPMeta from a JPEG image file stream.
   *
   * @param is the input stream containing the JPEG image file.
   * @return Extracted XMPMeta or null.
   */
  public static XMPMeta extractXMPMeta(InputStream is) {
    List<Section> sections = parse(is, true);
    if (sections == null) {
      return null;
    }
    // Now we don't support extended xmp.
    for (Section section : sections) {
      if (hasXMPHeader(section.data)) {
        int end = getXMPContentEnd(section.data);
        byte[] buffer = new byte[end - XMP_HEADER_SIZE];
        System.arraycopy(
            section.data, XMP_HEADER_SIZE, buffer, 0, buffer.length);
        try {
          XMPMeta result = XMPMetaFactory.parseFromBuffer(buffer);
          return result;
        } catch (XMPException e) {
          Log.d(TAG, "XMP parse error", e);
          return null;
        }
      }
    }
    return null;
  }

  /**
   * Creates a new XMPMeta.
   */
  public static XMPMeta createXMPMeta() {
    return XMPMetaFactory.create();
  }

  /**
   * Tries to extract XMP meta from image file first, if failed, create one.
   */
  public static XMPMeta extractOrCreateXMPMeta(String filename) {
    XMPMeta meta = extractXMPMeta(filename);
    return meta == null ? createXMPMeta() : meta;
  }

  /**
   * Writes the XMPMeta to the jpeg image file.
   */
  public static boolean writeXMPMeta(String filename, XMPMeta meta) {
    if (!filename.toLowerCase().endsWith(".jpg")
        && !filename.toLowerCase().endsWith(".jpeg")) {
      Log.d(TAG, "XMP parse: only jpeg file is supported");
      return false;
    }
    List<Section> sections = null;
    try {
      sections = parse(new FileInputStream(filename), false);
      sections = insertXMPSection(sections, meta);
      if (sections == null) {
        return false;
      }
    } catch (FileNotFoundException e) {
      Log.e(TAG, "Could not read file: " + filename, e);
      return false;
    }
    FileOutputStream os = null;
    try {
      // Overwrite the image file with the new meta data.
      os = new FileOutputStream(filename);
      writeJpegFile(os, sections);
    } catch (IOException e) {
      Log.d(TAG, "Write file failed:" + filename, e);
      return false;
    } finally {
      if (os != null) {
        try {
          os.close();
        } catch (IOException e) {
          // Ignore.
        }
      }
    }
    return true;
  }

  /**
   * Updates a jpeg file from inputStream with XMPMeta to outputStream.
   */
  public static boolean writeXMPMeta(InputStream inputStream, OutputStream outputStream,
      XMPMeta meta) {
    List<Section> sections = parse(inputStream, false);
      sections = insertXMPSection(sections, meta);
      if (sections == null) {
        return false;
      }
    try {
      // Overwrite the image file with the new meta data.
      writeJpegFile(outputStream, sections);
    } catch (IOException e) {
      Log.d(TAG, "Write to stream failed", e);
      return false;
    } finally {
      if (outputStream != null) {
        try {
          outputStream.close();
        } catch (IOException e) {
          // Ignore.
        }
      }
    }
    return true;
  }

  /**
   * Write a list of sections to a Jpeg file.
   */
  private static void writeJpegFile(OutputStream os, List<Section> sections)
      throws IOException {
    // Writes the jpeg file header.
    os.write(0xff);
    os.write(M_SOI);
    for (Section section : sections) {
      os.write(0xff);
      os.write(section.marker);
      if (section.length > 0) {
        // It's not the image data.
        int lh = section.length >> 8;
        int ll = section.length & 0xff;
        os.write(lh);
        os.write(ll);
      }
      os.write(section.data);
    }
  }

  private static List<Section> insertXMPSection(
      List<Section> sections, XMPMeta meta) {
    if (sections == null || sections.size() <= 1) {
      return null;
    }
    byte[] buffer;
    try {
      SerializeOptions options = new SerializeOptions();
      options.setUseCompactFormat(true);
      // We have to omit packet wrapper here because
      // javax.xml.parsers.DocumentBuilder
      // fails to parse the packet end <?xpacket end="w"?> in android.
      options.setOmitPacketWrapper(true);
      buffer = XMPMetaFactory.serializeToBuffer(meta, options);
    } catch (XMPException e) {
      Log.d(TAG, "Serialize xmp failed", e);
      return null;
    }
    if (buffer.length > MAX_XMP_BUFFER_SIZE) {
      // Do not support extended xmp now.
      return null;
    }
    // The XMP section starts with XMP_HEADER and then the real xmp data.
    byte[] xmpdata = new byte[buffer.length + XMP_HEADER_SIZE];
    System.arraycopy(XMP_HEADER.getBytes(), 0, xmpdata, 0, XMP_HEADER_SIZE);
    System.arraycopy(buffer, 0, xmpdata, XMP_HEADER_SIZE, buffer.length);
    Section xmpSection = new Section();
    xmpSection.marker = M_APP1;
    // Adds the length place (2 bytes) to the section length.
    xmpSection.length = xmpdata.length + 2;
    xmpSection.data = xmpdata;

    for (int i = 0; i < sections.size(); ++i) {
      // If we can find the old xmp section, replace it with the new one.
      if (sections.get(i).marker == M_APP1
          && hasXMPHeader(sections.get(i).data)) {
        // Replace with the new xmp data.
        sections.set(i, xmpSection);
        return sections;
      }
    }
    // If the first section is Exif, insert XMP data before the second section,
    // otherwise, make xmp data the first section.
    List<Section> newSections = new ArrayList<Section>();
    int position = (sections.get(0).marker == M_APP1) ? 1 : 0;
    newSections.addAll(sections.subList(0, position));
    newSections.add(xmpSection);
    newSections.addAll(sections.subList(position, sections.size()));
    return newSections;
  }

  /**
   * Checks whether the byte array has XMP header. The XMP section contains
   * a fixed length header XMP_HEADER.
   *
   * @param data Xmp metadata.
   */
  private static boolean hasXMPHeader(byte[] data) {
    if (data.length < XMP_HEADER_SIZE) {
      return false;
    }
    try {
      byte[] header = new byte[XMP_HEADER_SIZE];
      System.arraycopy(data, 0, header, 0, XMP_HEADER_SIZE);
      if (new String(header, "UTF-8").equals(XMP_HEADER)) {
        return true;
      }
    } catch (UnsupportedEncodingException e) {
      return false;
    }
    return false;
  }

  /**
   * Gets the end of the xmp meta content. If there is no packet wrapper,
   * return data.length, otherwise return 1 + the position of last '>'
   * without '?' before it.
   * Usually the packet wrapper end is "<?xpacket end="w"?> but
   * javax.xml.parsers.DocumentBuilder fails to parse it in android.
   *
   * @param data xmp metadata bytes.
   * @return The end of the xmp metadata content.
   */
  private static int getXMPContentEnd(byte[] data) {
    for (int i = data.length - 1; i >= 1; --i) {
      if (data[i] == '>') {
        if (data[i - 1] != '?') {
          return i + 1;
        }
      }
    }
    // It should not reach here for a valid xmp meta.
    return data.length;
  }

  /**
   * Parses the jpeg image file. If readMetaOnly is true, only keeps the Exif
   * and XMP sections (with marker M_APP1) and ignore others; otherwise, keep
   * all sections. The last section with image data will have -1 length.
   *
   * @param is Input image data stream.
   * @param readMetaOnly Whether only reads the metadata in jpg.
   * @return The parse result.
   */
  private static List<Section> parse(InputStream is, boolean readMetaOnly) {
    try {
      if (is.read() != 0xff || is.read() != M_SOI) {
        return null;
      }
      List<Section> sections = new ArrayList<Section>();
      int c;
      while ((c = is.read()) != -1) {
        if (c != 0xff) {
          return null;
        }
        // Skip padding bytes.
        while ((c = is.read()) == 0xff) {
        }
        if (c == -1) {
          return null;
        }
        int marker = c;
        if (marker == M_SOS) {
          // M_SOS indicates the image data will follow and no metadata after
          // that, so read all data at one time.
          if (!readMetaOnly) {
            Section section = new Section();
            section.marker = marker;
            section.length = -1;
            section.data = new byte[is.available()];
            is.read(section.data, 0, section.data.length);
            sections.add(section);
          }
          return sections;
        }
        int lh = is.read();
        int ll = is.read();
        if (lh == -1 || ll == -1) {
          return null;
        }
        int length = lh << 8 | ll;
        if (!readMetaOnly || c == M_APP1) {
          Section section = new Section();
          section.marker = marker;
          section.length = length;
          section.data = new byte[length - 2];
          is.read(section.data, 0, length - 2);
          sections.add(section);
        } else {
          // Skip this section since all exif/xmp meta will be in M_APP1
          // section.
          is.skip(length - 2);
        }
      }
      return sections;
    } catch (IOException e) {
      Log.d(TAG, "Could not parse file.", e);
      return null;
    } finally {
      if (is != null) {
        try {
          is.close();
        } catch (IOException e) {
          // Ignore.
        }
      }
    }
  }

  private XmpUtil() {}
}
