/*
 * Copyright (c) 2012, 2014, Oracle and/or its affiliates. All rights reserved.
 * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.
 *
 * This code is free software; you can redistribute it and/or modify it
 * under the terms of the GNU General Public License version 2 only, as
 * published by the Free Software Foundation.  Oracle designates this
 * particular file as subject to the "Classpath" exception as provided
 * by Oracle in the LICENSE file that accompanied this code.
 *
 * This code is distributed in the hope that it will be useful, but WITHOUT
 * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
 * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License
 * version 2 for more details (a copy is included in the LICENSE file that
 * accompanied this code).
 *
 * You should have received a copy of the GNU General Public License version
 * 2 along with this work; if not, write to the Free Software Foundation,
 * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.
 *
 * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA
 * or visit www.oracle.com if you need additional information or have any
 * questions.
 */

package com.sun.tools.jdeps;

import com.sun.tools.classfile.AccessFlags;
import com.sun.tools.classfile.ClassFile;
import com.sun.tools.classfile.ConstantPoolException;
import com.sun.tools.classfile.Dependencies.ClassFileError;

import jdk.internal.util.jar.VersionedStream;

import java.io.Closeable;
import java.io.File;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.InputStream;
import java.io.UncheckedIOException;
import java.nio.file.FileSystem;
import java.nio.file.FileSystems;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Enumeration;
import java.util.Iterator;
import java.util.List;
import java.util.NoSuchElementException;
import java.util.Set;
import java.util.jar.JarEntry;
import java.util.jar.JarFile;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import java.util.zip.ZipFile;

/**
 * ClassFileReader reads ClassFile(s) of a given path that can be
 * a .class file, a directory, or a JAR file.
 */
public class ClassFileReader implements Closeable {
    /**
     * Returns a ClassFileReader instance of a given path.
     */
    public static ClassFileReader newInstance(Path path) throws IOException {
        return newInstance(path, null);
    }

    /**
     * Returns a ClassFileReader instance of a given path.
     */
    public static ClassFileReader newInstance(Path path, Runtime.Version version) throws IOException {
        if (Files.notExists(path)) {
            throw new FileNotFoundException(path.toString());
        }

        if (Files.isDirectory(path)) {
            return new DirectoryReader(path);
        } else if (path.getFileName().toString().endsWith(".jar")) {
            return new JarFileReader(path, version);
        } else {
            return new ClassFileReader(path);
        }
    }

    /**
     * Returns a ClassFileReader instance of a given FileSystem and path.
     *
     * This method is used for reading classes from jrtfs.
     */
    public static ClassFileReader newInstance(FileSystem fs, Path path) throws IOException {
        return new DirectoryReader(fs, path);
    }

    protected final Path path;
    protected final String baseFileName;
    protected Set<String> entries; // binary names

    protected final List<String> skippedEntries = new ArrayList<>();
    protected ClassFileReader(Path path) {
        this.path = path;
        this.baseFileName = path.getFileName() != null
                                ? path.getFileName().toString()
                                : path.toString();
    }

    public String getFileName() {
        return baseFileName;
    }

    public List<String> skippedEntries() {
        return skippedEntries;
    }

    /**
     * Returns all entries in this archive.
     */
    public Set<String> entries() {
        Set<String> es = this.entries;
        if (es == null) {
            // lazily scan the entries
            this.entries = scan();
        }
        return this.entries;
    }

    /**
     * Returns the ClassFile matching the given binary name
     * or a fully-qualified class name.
     */
    public ClassFile getClassFile(String name) throws IOException {
        if (name.indexOf('.') > 0) {
            int i = name.lastIndexOf('.');
            String pathname = name.replace('.', File.separatorChar) + ".class";
            if (baseFileName.equals(pathname) ||
                    baseFileName.equals(pathname.substring(0, i) + "$" +
                                        pathname.substring(i+1, pathname.length()))) {
                return readClassFile(path);
            }
        } else {
            if (baseFileName.equals(name.replace('/', File.separatorChar) + ".class")) {
                return readClassFile(path);
            }
        }
        return null;
    }

    public Iterable<ClassFile> getClassFiles() throws IOException {
        return FileIterator::new;
    }

    protected ClassFile readClassFile(Path p) throws IOException {
        InputStream is = null;
        try {
            is = Files.newInputStream(p);
            return ClassFile.read(is);
        } catch (ConstantPoolException e) {
            throw new ClassFileError(e);
        } finally {
            if (is != null) {
                is.close();
            }
        }
    }

    protected Set<String> scan() {
        try {
            ClassFile cf = ClassFile.read(path);
            String name = cf.access_flags.is(AccessFlags.ACC_MODULE)
                ? "module-info" : cf.getName();
            return Collections.singleton(name);
        } catch (ConstantPoolException|IOException e) {
            throw new ClassFileError(e);
        }
    }

    static boolean isClass(Path file) {
        String fn = file.getFileName().toString();
        return fn.endsWith(".class");
    }

    @Override
    public void close() throws IOException {
    }

    class FileIterator implements Iterator<ClassFile> {
        int count;
        FileIterator() {
            this.count = 0;
        }
        public boolean hasNext() {
            return count == 0 && baseFileName.endsWith(".class");
        }

        public ClassFile next() {
            if (!hasNext()) {
                throw new NoSuchElementException();
            }
            try {
                ClassFile cf = readClassFile(path);
                count++;
                return cf;
            } catch (IOException e) {
                throw new ClassFileError(e);
            }
        }

        public void remove() {
            throw new UnsupportedOperationException("Not supported yet.");
        }
    }

    public String toString() {
        return path.toString();
    }

    private static class DirectoryReader extends ClassFileReader {
        protected final String fsSep;
        DirectoryReader(Path path) throws IOException {
            this(FileSystems.getDefault(), path);
        }
        DirectoryReader(FileSystem fs, Path path) throws IOException {
            super(path);
            this.fsSep = fs.getSeparator();
        }

        protected Set<String> scan() {
            try (Stream<Path> stream = Files.walk(path, Integer.MAX_VALUE)) {
                return stream.filter(ClassFileReader::isClass)
                             .map(path::relativize)
                             .map(Path::toString)
                             .map(p -> p.replace(File.separatorChar, '/'))
                             .collect(Collectors.toSet());
            } catch (IOException e) {
                throw new UncheckedIOException(e);
            }
        }

        public ClassFile getClassFile(String name) throws IOException {
            if (name.indexOf('.') > 0) {
                int i = name.lastIndexOf('.');
                String pathname = name.replace(".", fsSep) + ".class";
                Path p = path.resolve(pathname);
                if (Files.notExists(p)) {
                    p = path.resolve(pathname.substring(0, i) + "$" +
                            pathname.substring(i+1, pathname.length()));
                }
                if (Files.exists(p)) {
                    return readClassFile(p);
                }
            } else {
                Path p = path.resolve(name + ".class");
                if (Files.exists(p)) {
                    return readClassFile(p);
                }
            }
            return null;
        }

        public Iterable<ClassFile> getClassFiles() throws IOException {
            final Iterator<ClassFile> iter = new DirectoryIterator();
            return () -> iter;
        }

        class DirectoryIterator implements Iterator<ClassFile> {
            private final List<Path> entries;
            private int index = 0;
            DirectoryIterator() throws IOException {
                List<Path> paths = null;
                try (Stream<Path> stream = Files.walk(path, Integer.MAX_VALUE)) {
                    paths = stream.filter(ClassFileReader::isClass)
                                  .collect(Collectors.toList());
                }
                this.entries = paths;
                this.index = 0;
            }

            public boolean hasNext() {
                return index != entries.size();
            }

            public ClassFile next() {
                if (!hasNext()) {
                    throw new NoSuchElementException();
                }
                Path path = entries.get(index++);
                try {
                    return readClassFile(path);
                } catch (IOException e) {
                    throw new ClassFileError(e);
                }
            }

            public void remove() {
                throw new UnsupportedOperationException("Not supported yet.");
            }
        }
    }

    static class JarFileReader extends ClassFileReader {
        private final JarFile jarfile;
        private final Runtime.Version version;

        JarFileReader(Path path, Runtime.Version version) throws IOException {
            this(path, openJarFile(path.toFile(), version), version);
        }

        JarFileReader(Path path, JarFile jf, Runtime.Version version) throws IOException {
            super(path);
            this.jarfile = jf;
            this.version = version;
        }

        @Override
        public void close() throws IOException {
            jarfile.close();
        }

        private static JarFile openJarFile(File f, Runtime.Version version)
                throws IOException {
            JarFile jf;
            if (version == null) {
                jf = new JarFile(f, false);
                if (jf.isMultiRelease()) {
                    throw new MultiReleaseException("err.multirelease.option.notfound", f.getName());
                }
            } else {
                jf = new JarFile(f, false, ZipFile.OPEN_READ, version);
                if (!jf.isMultiRelease()) {
                    throw new MultiReleaseException("err.multirelease.option.exists", f.getName());
                }
            }
            return jf;
        }

        protected Set<String> scan() {
            try (JarFile jf = openJarFile(path.toFile(), version)) {
                return VersionedStream.stream(jf).map(JarEntry::getName)
                         .filter(n -> n.endsWith(".class"))
                         .collect(Collectors.toSet());
            } catch (IOException e) {
                throw new UncheckedIOException(e);
            }
        }

        public ClassFile getClassFile(String name) throws IOException {
            if (name.indexOf('.') > 0) {
                int i = name.lastIndexOf('.');
                String entryName = name.replace('.', '/') + ".class";
                JarEntry e = jarfile.getJarEntry(entryName);
                if (e == null) {
                    e = jarfile.getJarEntry(entryName.substring(0, i) + "$"
                            + entryName.substring(i + 1, entryName.length()));
                }
                if (e != null) {
                    return readClassFile(jarfile, e);
                }
            } else {
                JarEntry e = jarfile.getJarEntry(name + ".class");
                if (e != null) {
                    return readClassFile(jarfile, e);
                }
            }
            return null;
        }

        protected ClassFile readClassFile(JarFile jarfile, JarEntry e) throws IOException {
            try (InputStream is = jarfile.getInputStream(e)) {
                ClassFile cf = ClassFile.read(is);
                if (jarfile.isMultiRelease()) {
                    VersionHelper.add(jarfile, e, cf);
                }
                return cf;
            } catch (ConstantPoolException ex) {
                throw new ClassFileError(ex);
            }
        }

        public Iterable<ClassFile> getClassFiles() throws IOException {
            final Iterator<ClassFile> iter = new JarFileIterator(this, jarfile);
            return () -> iter;
        }
    }

    Enumeration<JarEntry> versionedEntries(JarFile jf) {
        Iterator<JarEntry> it = VersionedStream.stream(jf).iterator();
        return new Enumeration<>() {
            @Override
            public boolean hasMoreElements() {
                return it.hasNext();
            }

            @Override
            public JarEntry nextElement() {
                return it.next();
            }
        };
    }

    class JarFileIterator implements Iterator<ClassFile> {
        protected final JarFileReader reader;
        protected Enumeration<JarEntry> entries;
        protected JarFile jf;
        protected JarEntry nextEntry;
        protected ClassFile cf;
        JarFileIterator(JarFileReader reader) {
            this(reader, null);
        }
        JarFileIterator(JarFileReader reader, JarFile jarfile) {
            this.reader = reader;
            setJarFile(jarfile);
        }

        void setJarFile(JarFile jarfile) {
            if (jarfile == null) return;

            this.jf = jarfile;
            this.entries = versionedEntries(jf);
            this.nextEntry = nextEntry();
        }

        public boolean hasNext() {
            if (nextEntry != null && cf != null) {
                return true;
            }
            while (nextEntry != null) {
                try {
                    cf = reader.readClassFile(jf, nextEntry);
                    return true;
                } catch (ClassFileError | IOException ex) {
                    skippedEntries.add(String.format("%s: %s (%s)",
                                                     ex.getMessage(),
                                                     nextEntry.getName(),
                                                     jf.getName()));
                }
                nextEntry = nextEntry();
            }
            return false;
        }

        public ClassFile next() {
            if (!hasNext()) {
                throw new NoSuchElementException();
            }
            ClassFile classFile = cf;
            cf = null;
            nextEntry = nextEntry();
            return classFile;
        }

        protected JarEntry nextEntry() {
            while (entries.hasMoreElements()) {
                JarEntry e = entries.nextElement();
                String name = e.getName();
                if (name.endsWith(".class")) {
                    return e;
                }
            }
            return null;
        }

        public void remove() {
            throw new UnsupportedOperationException("Not supported yet.");
        }
    }
    private static final String MODULE_INFO = "module-info.class";
}
