/*
 * Copyright 2003-2015 JetBrains s.r.o.
 *
 * 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 jetbrains.mps.reloading;

import gnu.trove.THashSet;
import jetbrains.mps.project.MPSExtentions;
import jetbrains.mps.util.ConditionalIterable;
import jetbrains.mps.util.FileUtil;
import jetbrains.mps.util.InternUtil;
import jetbrains.mps.util.ReadUtil;
import jetbrains.mps.vfs.IFile;
import jetbrains.mps.vfs.impl.IoFileSystem;
import jetbrains.mps.vfs.openapi.FileSystem;
import org.apache.log4j.LogManager;
import org.apache.log4j.Logger;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.mps.util.Condition;

import java.io.BufferedInputStream;
import java.io.BufferedOutputStream;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.MalformedURLException;
import java.net.URL;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.Enumeration;
import java.util.Iterator;
import java.util.List;
import java.util.zip.ZipEntry;
import java.util.zip.ZipFile;

public class JarFileClassPathItem extends RealClassPathItem {
  private static final Logger LOG = LogManager.getLogger(JarFileClassPathItem.class);

  //computed during init
  private boolean myIsInitialized = false;
  private String myPrefix;
  private File myFile;

  private final MyCache myCache = new MyCache();
  private final String myPath;

  JarFileClassPathItem(FileSystem fileSystem, String path) {
    myPath = path;
    if (path.endsWith("!/")) {
      path = path.substring(0, path.length() - 2);
    }
    try {
      myFile = transformFile(fileSystem.getFile(path));
      myPrefix = "jar:" + myFile.toURI().toURL() + "!/";
    } catch (IOException e) {
      LOG.error("invalid class path: " + path, e);
    }
  }

  @Override
  public String getPath() {
    return myPath;
  }

  public String getAbsolutePath() {
    return myFile.getAbsolutePath();
  }

  public File getFile() {
    return myFile;
  }

  @Override
  public boolean hasClass(String qualifiedClassName) {
    ensureInitialized();
    final int ix = qualifiedClassName.lastIndexOf('.');
    String packageName = ix == -1 ? "" : qualifiedClassName.substring(0, ix);
    String className = qualifiedClassName.substring(ix + 1);
    return myCache.hasClass(packageName, className);
  }

  @Override
  public boolean hasPackage(@NotNull String packageName) {
    ensureInitialized();
    return myCache.hasPackage(packageName);
  }

  @Override
  public synchronized ClassBytes getClassBytes(String qualifiedClassName) {
    ensureInitialized();
    InputStream inp = null;
    ZipFile zf = null;
    try {
      zf = new ZipFile(myFile);
      String entryName = toClassEntry(qualifiedClassName);
      ZipEntry entry = zf.getEntry(entryName);
      if (entry == null) {
        return null;
      }
      inp = zf.getInputStream(entry);
      if (inp == null) {
        return null;
      }
      // safe to assume int as class files have size limit way lower than 2^31
      byte[] bytes = ReadUtil.read(inp, (int) entry.getSize());
      return bytes == null ? null : new DefaultClassBytes(bytes, myFile.toURI().toURL());
    } catch (IOException e) {
      LOG.error(getClass().getName(), e);
      return null;
    } finally {
      FileUtil.closeFileSafe(inp);
      closeZipFile(zf);
    }
  }

  private static String toClassEntry(String classQualifiedName) {
    StringBuilder sb = new StringBuilder(classQualifiedName);
    for (int i = 0; i < classQualifiedName.length(); i++) {
      if (sb.charAt(i) == '.') {
        sb.setCharAt(i, '/');
      }
    }
    sb.append(MPSExtentions.DOT_CLASSFILE);
    return sb.toString();
  }

  private static void closeZipFile(ZipFile zf) {
    if (zf != null) {
      try {
        zf.close();
      } catch (IOException e) {
        LOG.error(JarFileClassPathItem.class.getName(), e);
      }
    }
  }

  @Override
  public URL getResource(String name) {
    ZipFile zf = null;
    try {
      zf = new ZipFile(myFile);
      if (zf.getEntry(name) == null) return null;
      return new URL(myPrefix + name);
    } catch (MalformedURLException e) {
      LOG.error(null, e);
      return null;
    } catch (IOException e) {
      LOG.error(null, e);
      return null;
    } finally {
      if (zf != null) {
        try {
          zf.close();
        } catch (IOException e) {
          LOG.error(null, e);
        }
      }
    }
  }

  @Override
  public synchronized Iterable<String> getAvailableClasses(String namespace) {
    ensureInitialized();
    Collection<String> start = myCache.getClassesSetFor(namespace);
    Condition<String> cond = new Condition<String>() {
      @Override
      public boolean met(String className) {
        return !isAnonymous(className);
      }
    };
    return new ConditionalIterable<String>(start, cond);
  }

  @Override
  public synchronized Iterable<String> getSubpackages(String namespace) {
    ensureInitialized();
    return myCache.getSubpackagesSetFor(namespace);
  }

  @Override
  public List<RealClassPathItem> flatten() {
    List<RealClassPathItem> result = new ArrayList<RealClassPathItem>();
    result.add(this);
    return result;
  }

  @Override
  public void accept(IClassPathItemVisitor visitor) {
    visitor.visit(this);
  }

  public String toString() {
    return "jar-cp: " + myFile;
  }

  private void ensureInitialized() {
    if (myIsInitialized) return;
    myIsInitialized = true;
    buildCaches();
  }

  private long getClassTimestamp(String name) {
    String path = name.replace('.', '/') + ".class";
    ZipFile zf = null;
    try {
      zf = new ZipFile(myFile);

      ZipEntry entry = zf.getEntry(path);
      assert entry != null : path;
      return entry.getTime();
    } catch (IOException e) {
      LOG.error(null, e);
      return 0;
    } finally {
      if (zf != null) {
        try {
          zf.close();
        } catch (IOException e) {
          LOG.error(null, e);
        }
      }
    }
  }

  private synchronized void buildCaches() {
    ZipFile zf = null;
    try {
      zf = new ZipFile(myFile);
      Enumeration<? extends ZipEntry> entries = zf.entries();

      while (entries.hasMoreElements()) {
        ZipEntry entry = entries.nextElement();
        if (!entry.isDirectory()) {
          String name = entry.getName();

          if (!name.endsWith(MPSExtentions.DOT_CLASSFILE)) continue;

          int packEnd = name.lastIndexOf('/');
          String pack;
          String className;
          if (packEnd == -1) {
            pack = "";
            className = name.substring(0, name.length() - MPSExtentions.DOT_CLASSFILE.length());
          } else {
            pack = packEnd > 0 ? name.substring(0, packEnd).replace('/', '.') : name;
            className = name.substring(packEnd + 1, name.length() - MPSExtentions.DOT_CLASSFILE.length());
          }

          myCache.addClass(pack, InternUtil.intern(className));
        }
      }
    } catch (IOException e) {
      LOG.error(String.format("Path %s (%s) \nFile exists: %s", myFile.getPath(), myFile.getAbsolutePath(), myFile.exists()), e);
    } finally {
      if (zf != null) {
        try {
          zf.close();
        } catch (IOException e) {
          LOG.error(null, e);
        }
      }
    }
  }

  private static File transformFile(IFile f) throws IOException {
    if (!f.isInArchive()) {
      return new File(f.getPath());
    }

    File tmpFile = File.createTempFile(f.getName(), "tmp");
    tmpFile.deleteOnExit();

    OutputStream os = null;
    InputStream is = null;
    try {
      is = new BufferedInputStream(f.openInputStream());
      os = new BufferedOutputStream(new FileOutputStream(tmpFile));
      int result;
      while ((result = is.read()) != -1) {
        os.write(result);
      }
    } finally {
      if (is != null) {
        is.close();
      }
      if (os != null) {
        os.close();
      }
    }

    return tmpFile;
  }

  //do not touch this class if you are not sure in your changes - this can lead to excess memory consumption (see #53513)
  private static class MyCache {
    private final Entry myTopPackage = new Entry("");

    private Entry getEntry(String pack) {
      Entry e = myTopPackage;
      PackageNameIterator it = new PackageNameIterator(pack);
      while (it.hasNext() && e != null) {
        e = e.getSubPackage(it.next());
      }
      return e;
    }
    public Collection<String> getClassesSetFor(String pack) {
      Entry e = getEntry(pack);
      return e == null ? Collections.<String>emptyList() : e.getClasses();
    }

    public boolean hasClass(String pack, String className) {
      Entry e = getEntry(pack);
      return e != null && e.hasClass(className);
    }

    public boolean hasPackage(String pack) {
      return getEntry(pack) != null;
    }

    public Collection<String> getSubpackagesSetFor(String pack) {
      Entry e = getEntry(pack);
      return e == null ? Collections.<String>emptyList() : e.getImmediateSubPackages(pack);
    }

    public void addClass(String pack, String className) {
      //namespace is never null;
      Entry e = myTopPackage;
      PackageNameIterator it = new PackageNameIterator(pack);
      while (it.hasNext()) {
        e = e.createSubPackage(it.next());
      }
      e.addClass(className);
    }
  }

  /**
   * PackageNameIterator("").hasNext() == false
   *  PackageNameIterator("a").hasNext() == true
   *  PackageNameIterator("a").hasNext().hasNext() == false
   */
  private static class PackageNameIterator implements Iterator<String> {
    private final String myPackageName;
    private int start = 0;
    private int dotIndex;

    public PackageNameIterator(String packageName) {
      myPackageName = packageName;
      advance();
    }

    @Override
    public boolean hasNext() {
      return dotIndex > 0 && dotIndex <= myPackageName.length();
    }

    @Override
    public String next() {
      String rv = myPackageName.substring(start, dotIndex);
      start = dotIndex + 1;
      advance();
      return rv;
    }

    private void advance() {
      dotIndex = myPackageName.indexOf('.', start);
      if (dotIndex == -1 && start < myPackageName.length()) {
        dotIndex = myPackageName.length();
      }
    }

    @Override
    public void remove() {
      throw new UnsupportedOperationException();
    }
  }

  private static class Entry {
    private final String myPackageName;
    private ArrayList<Entry> mySubpackages;
    private THashSet<String> myClassNames;

    public Entry(String packageName) {
      myPackageName = packageName;
    }

    public Entry createSubPackage(String packageNamePart) {
      if (mySubpackages == null) {
        mySubpackages = new ArrayList<Entry>(4);
        final Entry rv = new Entry(new String(packageNamePart));
        mySubpackages.add(rv);
        return rv;
      }
      final int ix = indexOf(packageNamePart);
      if (ix < 0) {
        final Entry rv = new Entry(new String(packageNamePart));
        mySubpackages.add(-ix - 1, rv);
        return rv;
      } else {
        return mySubpackages.get(ix);
      }
    }

    public Entry getSubPackage(String packageNamePart) {
      final int ix = indexOf(packageNamePart);
      if (ix < 0) {
        return null;
      }
      return mySubpackages.get(ix);
    }

    public void addClass(String className) {
      if (myClassNames == null) {
        myClassNames = new THashSet<String>();
      }
      myClassNames.add(className);
    }

    public boolean hasClass(String className) {
      return myClassNames != null && myClassNames.contains(className);
    }

    public Collection<String> getImmediateSubPackages(String parent) {
      if (mySubpackages == null) {
        return Collections.emptyList();
      }
      ArrayList<String> rv = new ArrayList<String>(mySubpackages.size());
      for (Entry e : mySubpackages) {
        if (parent == null || parent.isEmpty()) {
          rv.add(e.myPackageName);
        } else {
          rv.add(parent + '.' + e.myPackageName);
        }
      }
      return rv;
    }

    public Collection<String> getClasses() {
      return myClassNames == null ? Collections.<String>emptyList() : myClassNames;
    }

    @Override
    public String toString() {
      return String.format("%s - %d;%d", myPackageName, mySubpackages == null ? 0 : mySubpackages.size(), myClassNames == null ? 0 : myClassNames.size());
    }

    private int indexOf(String packageName) {
      if (mySubpackages == null) {
        return -1;
      }
      int low = 0;
      int high = mySubpackages.size() - 1;
      while (low <= high) {
        int mid = (low + high) >>> 1;
        Entry c = mySubpackages.get(mid);
        int cmp = packageName.compareTo(c.myPackageName);
        if (cmp < 0) {
          high = mid - 1;
        } else if (cmp > 0) {
          low = mid + 1;
        } else {
          return mid;
        }
      }
      return -(low + 1);
    }
  }
}
