package org.basex.query.func;

import static org.basex.query.QueryText.*;
import static org.basex.query.util.Err.*;
import static org.basex.util.Token.*;

import java.io.BufferedInputStream;
import java.io.BufferedOutputStream;
import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.util.Enumeration;
import java.util.Iterator;
import java.util.Random;
import java.util.TreeSet;
import java.util.zip.ZipEntry;
import java.util.zip.ZipFile;
import java.util.zip.ZipOutputStream;

import org.basex.build.Parser;
import org.basex.build.file.HTMLParser;
import org.basex.core.Prop;
import org.basex.io.IO;
import org.basex.io.IOContent;
import org.basex.io.IOFile;
import org.basex.io.Zip;
import org.basex.io.in.TextInput;
import org.basex.io.serial.Serializer;
import org.basex.io.serial.SerializerException;
import org.basex.io.serial.SerializerProp;
import org.basex.query.QueryContext;
import org.basex.query.QueryException;
import org.basex.query.expr.Expr;
import org.basex.query.item.ANode;
import org.basex.query.item.B64;
import org.basex.query.item.DBNode;
import org.basex.query.item.FAttr;
import org.basex.query.item.FElem;
import org.basex.query.item.Hex;
import org.basex.query.item.Item;
import org.basex.query.item.NodeType;
import org.basex.query.item.QNm;
import org.basex.query.item.Str;
import org.basex.query.item.Uri;
import org.basex.query.iter.AxisIter;
import org.basex.query.util.DataBuilder;
import org.basex.util.Atts;
import org.basex.util.InputInfo;
import org.basex.util.TokenBuilder;
import org.basex.util.list.ByteList;
import org.basex.util.list.StringList;

/**
 * Functions on zip files.
 *
 * @author BaseX Team 2005-11, BSD License
 * @author Christian Gruen
 */
public final class FNZip extends FuncCall {
  /** Function namespace. */
  private static final Uri U_ZIP = Uri.uri(ZIPURI);
  /** Element: zip:file. */
  private static final QNm E_FILE = new QNm(token("zip:file"), U_ZIP);
  /** Element: zip:dir. */
  private static final QNm E_DIR = new QNm(token("zip:dir"), U_ZIP);
  /** Element: zip:entry. */
  private static final QNm E_ENTRY = new QNm(token("zip:entry"), U_ZIP);
  /** Attribute: href. */
  private static final QNm A_HREF = new QNm(token("href"));
  /** Attribute: name. */
  private static final QNm A_NAME = new QNm(token("name"));
  /** Attribute: src. */
  private static final QNm A_SRC = new QNm(token("src"));
  /** Attribute: src. */
  private static final QNm A_METHOD = new QNm(token("method"));
  /** Method "base64". */
  private static final String M_BASE64 = "base64";
  /** Method "hex". */
  private static final String M_HEX = "hex";

  /**
   * Constructor.
   * @param ii input info
   * @param f function definition
   * @param e arguments
   */
  public FNZip(final InputInfo ii, final Function f, final Expr... e) {
    super(ii, f, e);
  }

  @Override
  public Item item(final QueryContext ctx, final InputInfo ii)
      throws QueryException {

    checkAdmin(ctx);
    switch(def) {
      case ZIPBIN:     return binaryEntry(ctx);
      case ZIPTEXT:    return textEntry(ctx);
      case ZIPHTML:    return xmlEntry(ctx, true);
      case ZIPXML:     return xmlEntry(ctx, false);
      case ZIPENTRIES: return entries(ctx);
      case ZIPFILE:    return zipFile(ctx);
      case ZIPUPDATE:  return updateEntries(ctx);
      default:         return super.item(ctx, ii);
    }
  }

  /**
   * Returns a xs:base64Binary item, created from a binary file.
   * Returns a binary entry.
   * @param ctx query context
   * @return binary result
   * @throws QueryException query exception
   */
  private B64 binaryEntry(final QueryContext ctx) throws QueryException {
    return new B64(entry(ctx));
  }

  /**
   * Returns a string, created from a text file.
   * @param ctx query context
   * @return binary result
   * @throws QueryException query exception
   */
  private Str textEntry(final QueryContext ctx) throws QueryException {
    final String enc = expr.length < 3 ? null : string(checkStr(expr[2], ctx));
    final IO io = new IOContent(entry(ctx));
    try {
      return Str.get(TextInput.content(io, enc).finish());
    } catch(final IOException ex) {
      throw ZIPFAIL.thrw(input, ex.getMessage());
    }
  }

  /**
   * Returns a document node, created from an XML or HTML file.
   * @param ctx query context
   * @param html html flag
   * @return binary result
   * @throws QueryException query exception
   */
  private ANode xmlEntry(final QueryContext ctx, final boolean html)
      throws QueryException {

    final Prop prop = ctx.context.prop;
    final IO io = new IOContent(entry(ctx));
    try {
      return new DBNode(html ? new HTMLParser(io, "", prop) :
        Parser.xmlParser(io, prop), prop);
    } catch(final IOException ex) {
      throw SAXERR.thrw(input, ex);
    }
  }

  /**
   * Returns a zip archive description.
   * @param ctx query context
   * @return binary result
   * @throws QueryException query exception
   */
  private ANode entries(final QueryContext ctx) throws QueryException {
    final String file = string(checkStr(expr[0], ctx));

    // check file path
    final IOFile path = new IOFile(file);
    if(!path.exists()) ZIPNOTFOUND.thrw(input, file);
    // loop through file
    ZipFile zf = null;
    try {
      zf = new ZipFile(file);
      // create result node
      final FElem root = new FElem(E_FILE, new Atts().add(ZIP, ZIPURI), null);
      root.add(new FAttr(A_HREF, token(path.path())));
      createEntries(paths(zf).iterator(), root, "");
      return root;
    } catch(final IOException ex) {
      throw ZIPFAIL.thrw(input, ex.getMessage());
    } finally {
      if(zf != null) try { zf.close(); } catch(final IOException e) { }
    }
  }

  /**
   * Creates the zip archive nodes in a recursive manner.
   * @param it iterator
   * @param par parent node
   * @param pref directory prefix
   * @return current prefix
   */
  private String createEntries(final Iterator<String> it,
      final FElem par, final String pref) {

    String path = null;
    boolean curr = false;
    while(curr || it.hasNext()) {
      if(!curr) {
        path = it.next();
        curr = true;
      }
      if(path == null) break;
      // current entry is located in a higher/other directory
      if(!path.startsWith(pref)) return path;

      // current file starts with new directory
      final int i = path.lastIndexOf('/');
      final String dir = i == -1 ? path : path.substring(0, i);
      final String name = path.substring(i + 1);

      if(name.isEmpty()) {
        // path ends with slash: create directory
        path = createEntries(it, createDir(par, dir), dir);
      } else {
        // create file
        createFile(par, name);
        curr = false;
      }
    }
    return null;
  }

  /**
   * Creates a directory element.
   * @param par parent node
   * @param name name of directory
   * @return element
   */
  private FElem createDir(final FElem par, final String name) {
    final FElem e = new FElem(E_DIR);
    e.add(new FAttr(A_NAME, token(name)));
    par.add(e);
    return e;
  }

  /**
   * Creates a file element.
   * @param par parent node
   * @param name name of directory
   */
  private void createFile(final FElem par, final String name) {
    final FElem e = new FElem(E_ENTRY);
    e.add(new FAttr(A_NAME, token(name)));
    par.add(e);
  }

  /**
   * Creates a new zip file.
   * @param ctx query context
   * @return binary result
   * @throws QueryException query exception
   */
  private Item zipFile(final QueryContext ctx) throws QueryException {
    // check argument
    final ANode elm = (ANode) checkType(expr[0].item(ctx, input), NodeType.ELM);
    if(!elm.qname().eq(E_FILE)) ZIPUNKNOWN.thrw(input, elm.qname());
    // get file
    final String file = attribute(elm, A_HREF, true);

    // write zip file
    FileOutputStream fos = null;
    boolean ok = true;
    try {
      fos = new FileOutputStream(file);
      final ZipOutputStream zos =
        new ZipOutputStream(new BufferedOutputStream(fos));
      create(zos, elm.children(), "", null, ctx);
      zos.close();
    } catch(final IOException ex) {
      ok = false;
      ZIPFAIL.thrw(input, ex.getMessage());
    } finally {
      if(fos != null) {
        try { fos.close(); } catch(final IOException ex) { }
        if(!ok) new IOFile(file).delete();
      }
    }
    return null;
  }

  /**
   * Adds files to the specified zip output, or copies files from the
   * specified file.
   * @param zos output stream
   * @param ai axis iterator
   * @param root root path
   * @param ctx query context
   * @param zf original zip file (or {@code null})
   * @throws QueryException query exception
   * @throws IOException I/O exception
   */
  private void create(final ZipOutputStream zos, final AxisIter ai,
      final String root, final ZipFile zf, final QueryContext ctx)
      throws QueryException, IOException {

    final byte[] data = new byte[IO.BLOCKSIZE];
    for(ANode node; (node = ai.next()) != null;) {
      // get entry type
      final QNm mode = node.qname();
      final boolean dir = mode.eq(E_DIR);
      if(!dir && !mode.eq(E_ENTRY)) ZIPUNKNOWN.thrw(input, mode);

      // file path: if null, the zip base name is used
      String name = attribute(node, A_NAME, false);
      // source: if null, the node's children are serialized
      String src = attribute(node, A_SRC, false);
      if(src != null) src = src.replaceAll("\\\\", "/");

      if(name == null) {
        // throw exception if both attributes are null
        if(src == null) throw ZIPINVALID.thrw(input, node.qname(), A_SRC);
        name = src;
      }
      name = name.replaceAll(".*/", "");

      // add slash to directories
      if(dir) name += '/';
      zos.putNextEntry(new ZipEntry(root + name));

      if(dir) {
        create(zos, node.children(), root + name, zf, ctx);
      } else {
        if(src != null) {
          // write file to zip archive
          if(!new IOFile(src).exists()) ZIPNOTFOUND.thrw(input, src);

          BufferedInputStream bis = null;
          try {
            bis = new BufferedInputStream(new FileInputStream(src));
            for(int c; (c = bis.read(data)) != -1;) zos.write(data, 0, c);
          } finally {
            if(bis != null) try { bis.close(); } catch(final IOException e) { }
          }
        } else {
          // no source reference: the child nodes are treated as file contents
          final AxisIter ch = node.children();
          final String m = attribute(node, A_METHOD, false);
          // retrieve first child (might be null)
          ANode n = ch.next();

          // access original zip file if available, and if no children exist
          ZipEntry ze = null;
          if(zf != null && n == null) ze = zf.getEntry(root + name);

          if(ze != null) {
            // add old zip entry
            final InputStream zis = zf.getInputStream(ze);
            for(int c; (c = zis.read(data)) != -1;) zos.write(data, 0, c);
          } else if(n != null) {
            // write new binary content to archive
            final boolean hex = M_HEX.equals(m);
            if(hex || M_BASE64.equals(m)) {
              // treat children as base64/hex
              final ByteList bl = new ByteList();
              do bl.add(n.atom()); while((n = ch.next()) != null);
              final byte[] bytes = bl.toArray();
              zos.write((hex ? new Hex(bytes) : new B64(bytes)).toJava());
            } else {
              // serialize new nodes
              try {
                final Serializer ser = Serializer.get(zos, serPar(node, ctx));
                do {
                  DataBuilder.stripNS(n, ZIPURI, ctx).serialize(ser);
                } while((n = ch.next()) != null);
                ser.close();
              } catch(final SerializerException ex) {
                throw new QueryException(input, ex);
              }
            }
          }
        }
        zos.closeEntry();
      }
    }
  }

  /**
   * Returns serialization parameters.
   * @param node node with parameters
   * @param ctx query context
   * @return properties
   * @throws SerializerException serializer exception
   */
  private SerializerProp serPar(final ANode node, final QueryContext ctx)
      throws SerializerException {

    // interpret query parameters
    final TokenBuilder tb = new TokenBuilder();
    final AxisIter ati = node.attributes();
    for(ANode at; (at = ati.next()) != null;) {
      final QNm name = at.qname();
      if(name.eq(A_NAME) || name.eq(A_SRC)) continue;
      if(tb.size() != 0) tb.add(',');
      tb.add(name.ln()).add('=').add(at.atom());
    }
    return tb.size() == 0 ? ctx.serProp(true) :
      new SerializerProp(tb.toString());
  }

  /**
   * Updates a zip archive.
   * @param ctx query context
   * @return empty result
   * @throws QueryException query exception
   */
  private Item updateEntries(final QueryContext ctx) throws QueryException {
    // check argument
    final ANode elm = (ANode) checkType(expr[0].item(ctx, input), NodeType.ELM);
    if(!elm.qname().eq(E_FILE)) ZIPUNKNOWN.thrw(input, elm.qname());

    // sorted paths in original file
    final String in = attribute(elm, A_HREF, true);

    // target and temporary output file
    final IOFile target = new IOFile(string(checkStr(expr[1], ctx)));
    IOFile out;
    do {
      out = new IOFile(target.path() + new Random().nextInt(0x7FFFFFFF));
    } while(out.exists());

    // open zip file
    if(!new IOFile(in).exists()) ZIPNOTFOUND.thrw(input, in);
    ZipFile zf = null;
    boolean ok = true;
    try {
      zf = new ZipFile(in);
      // write zip file
      FileOutputStream fos = null;
      try {
        fos = new FileOutputStream(out.path());
        final ZipOutputStream zos =
          new ZipOutputStream(new BufferedOutputStream(fos));
        // fill new zip file with entries from old file and description
        create(zos, elm.children(), "", zf, ctx);
        zos.close();
      } catch(final IOException ex) {
        ok = false;
        ZIPFAIL.thrw(input, ex.getMessage());
      } finally {
        if(fos != null) try { fos.close(); } catch(final IOException ex) { }
      }
    } catch(final IOException ex) {
      throw ZIPFAIL.thrw(input, ex.getMessage());
    } finally {
      if(zf != null) try { zf.close(); } catch(final IOException e) { }
      if(ok) {
        // rename temporary file to final target
        target.delete();
        out.rename(target);
      } else {
        // remove temporary file
        out.delete();
      }
    }
    return null;
  }

  /**
   * Returns a list of all file paths.
   * @param zf zip file file to be parsed
   * @return binary result
   */
  private StringList paths(final ZipFile zf) {
    // traverse all zip entries and create intermediate map,
    // as zip entries are not sorted
    //final StringList paths = new StringList();
    final TreeSet<String> paths = new TreeSet<String>();

    final Enumeration<? extends ZipEntry> en = zf.entries();
    // loop through all files
    while(en.hasMoreElements()) {
      final ZipEntry ze = en.nextElement();
      final String name = ze.getName();
      final int i = name.lastIndexOf('/');
      // add directory
      if(i > -1 && i + 1 < name.length()) paths.add(name.substring(0, i + 1));
      paths.add(name);
    }
    final StringList sl = new StringList();
    final Iterator<String> it = paths.iterator();
    while(it.hasNext()) sl.add(it.next());
    return sl;
  }

  /**
   * Returns the value of the specified attribute.
   * @param elm element node
   * @param name attribute to be found
   * @param force if set to {@code true}, an exception is thrown if the
   * attribute is not found
   * @return attribute value
   * @throws QueryException query exception
   */
  private String attribute(final ANode elm, final QNm name, final boolean force)
      throws QueryException {

    final byte[] val = elm.attribute(name);
    if(val == null && force) throw ZIPINVALID.thrw(input, elm.qname(), name);
    return val == null ? null : string(val);
  }

  /**
   * Returns an entry from a zip file.
   * @param ctx query context
   * @return binary result
   * @throws QueryException query exception
   */
  private byte[] entry(final QueryContext ctx) throws QueryException {
    final IOFile file = new IOFile(string(checkStr(expr[0], ctx)));
    final String path = string(checkStr(expr[1], ctx));
    if(!file.exists()) ZIPNOTFOUND.thrw(input, file);

    try {
      return new Zip(file).read(path);
    } catch(final FileNotFoundException ex) {
      throw ZIPNOTFOUND.thrw(input, file + "/" + path);
    } catch(final IOException ex) {
      throw ZIPFAIL.thrw(input, ex.getMessage());
    }
  }

  @Override
  public boolean uses(final Use u) {
    return u == Use.CTX;
  }
}
