/*
 * Copyright (c) 2016, 2024, 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 sun.tools.jar;

import java.io.IOException;
import java.lang.reflect.AccessFlag;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.HashSet;
import java.util.Set;
import java.util.function.Consumer;
import java.lang.classfile.AccessFlags;
import java.lang.classfile.Attributes;
import java.lang.classfile.ClassElement;
import java.lang.classfile.ClassFile;
import java.lang.classfile.constantpool.*;
import java.lang.classfile.FieldModel;
import java.lang.classfile.MethodModel;
import java.lang.classfile.attribute.EnclosingMethodAttribute;
import java.lang.classfile.attribute.InnerClassesAttribute;

/**
 * A FingerPrint is an abstract representation of a JarFile entry that contains
 * information to determine if the entry represents a class or a
 * resource, and whether two entries are identical.  If the FingerPrint represents
 * a class, it also contains information to (1) describe the public API;
 * (2) compare the public API of this class with another class;  (3) determine
 * whether or not it's a nested class and, if so, the name of the associated
 * outer class; and (4) for an canonically ordered set of classes determine
 * if the class versions are compatible.  A set of classes is canonically
 * ordered if the classes in the set have the same name, and the base class
 * precedes the versioned classes and if each versioned class with version
 * {@code n} precedes classes with versions {@code > n} for all versions
 * {@code n}.
 */
final class FingerPrint {
    private static final MessageDigest MD;

    private final String basename;
    private final String entryName;
    private final int mrversion;

    private final byte[] sha1;
    private final ClassAttributes attrs;
    private final boolean isClassEntry;

    static {
        try {
            MD = MessageDigest.getInstance("SHA-1");
        } catch (NoSuchAlgorithmException x) {
            // log big problem?
            throw new RuntimeException(x);
        }
    }

    public FingerPrint(String basename, String entryName, int mrversion, byte[] bytes)
            throws IOException {
        this.basename = basename;
        this.entryName = entryName;
        this.mrversion = mrversion;
        if (isCafeBabe(bytes)) {
            isClassEntry = true;
            sha1 = sha1(bytes, 8);  // skip magic number and major/minor version
            attrs = getClassAttributes(bytes);
        } else {
            isClassEntry = false;
            sha1 = null;
            attrs = null;
        }
    }

    public boolean isClass() {
        return isClassEntry;
    }

    public boolean isNestedClass() {
        return attrs.maybeNestedClass && attrs.outerClassName != null;
    }

    public boolean isPublicClass() {
        return attrs.publicClass;
    }

    public boolean isIdentical(FingerPrint that) {
        if (that == null) return false;
        if (this == that) return true;
        return isEqual(this.sha1, that.sha1);
    }

    public boolean isCompatibleVersion(FingerPrint that) {
        return attrs.majorVersion >= that.attrs.majorVersion;
    }

    public boolean isSameAPI(FingerPrint that) {
        if (that == null) return false;
        return attrs.equals(that.attrs);
    }

    public String basename() {
        return basename;
    }

    public String entryName() {
        return entryName;
    }

    public String className() {
        return attrs.name;
    }

    public int classMajorVersion() {
        return attrs.majorVersion; // ..., 53, 54, ...
    }

    public int classReleaseVersion() {
        return attrs.majorVersion - 44; // ..., 53 -> 9, 54 -> 10, ...
    }

    public int mrversion() {
        return mrversion;
    }

    public String outerClassName() {
        return attrs.outerClassName;
    }

    private byte[] sha1(byte[] entry) {
        MD.update(entry);
        return MD.digest();
    }

    private byte[] sha1(byte[] entry, int offset) {
        MD.update(entry, offset, entry.length - offset);
        return MD.digest();
    }

    private boolean isEqual(byte[] sha1_1, byte[] sha1_2) {
        return MessageDigest.isEqual(sha1_1, sha1_2);
    }

    private static final byte[] cafeBabe = {(byte)0xca, (byte)0xfe, (byte)0xba, (byte)0xbe};

    private boolean isCafeBabe(byte[] bytes) {
        if (bytes.length < 4) return false;
        for (int i = 0; i < 4; i++) {
            if (bytes[i] != cafeBabe[i]) {
                return false;
            }
        }
        return true;
    }

    private static ClassAttributes getClassAttributes(byte[] bytes) {
        var cm = ClassFile.of().parse(bytes);
        ClassAttributes attrs = new ClassAttributes(
                cm.flags(),
                cm.thisClass().asInternalName(),
                cm.superclass().map(ClassEntry::asInternalName).orElse(null),
                cm.majorVersion());
        cm.forEach(attrs);
        return attrs;
    }

    private static final class Field {
        private final int access;
        private final String name;
        private final String desc;

        Field(int access, String name, String desc) {
            this.access = access;
            this.name = name;
            this.desc = desc;
        }

        @Override
        public boolean equals(Object that) {
            if (that == null) return false;
            if (this == that) return true;
            if (!(that instanceof Field)) return false;
            Field field = (Field)that;
            return (access == field.access) && name.equals(field.name)
                    && desc.equals(field.desc);
        }

        @Override
        public int hashCode() {
            int result = 17;
            result = 37 * result + access;
            result = 37 * result + name.hashCode();
            result = 37 * result + desc.hashCode();
            return result;
        }
    }

    private static final class Method {
        private final int access;
        private final String name;
        private final String desc;
        private final Set<String> exceptions;

        Method(int access, String name, String desc, Set<String> exceptions) {
            this.access = access;
            this.name = name;
            this.desc = desc;
            this.exceptions = exceptions;
        }

        @Override
        public boolean equals(Object that) {
            if (that == null) return false;
            if (this == that) return true;
            if (!(that instanceof Method)) return false;
            Method method = (Method)that;
            return (access == method.access) && name.equals(method.name)
                    && desc.equals(method.desc)
                    && exceptions.equals(method.exceptions);
        }

        @Override
        public int hashCode() {
            int result = 17;
            result = 37 * result + access;
            result = 37 * result + name.hashCode();
            result = 37 * result + desc.hashCode();
            result = 37 * result + exceptions.hashCode();
            return result;
        }
    }

    private static final class ClassAttributes implements Consumer<ClassElement> {
        private final String name;
        private String outerClassName;
        private final String superName;
        private final int majorVersion;
        private final int access;
        private final boolean publicClass;
        private final boolean maybeNestedClass;
        private final Set<Field> fields = new HashSet<>();
        private final Set<Method> methods = new HashSet<>();

        public ClassAttributes(AccessFlags access, String name, String superName, int majorVersion) {
            this.majorVersion = majorVersion; // JDK-8296329: extract major version only
            this.access = access.flagsMask();
            this.name = name;
            this.maybeNestedClass = name.contains("$");
            this.superName = superName;
            this.publicClass = isPublic(access);
        }

        @Override
        public void accept(ClassElement cle) {
            switch (cle) {
                case InnerClassesAttribute ica -> {
                    for (var icm : ica.classes()) {
                        if (this.maybeNestedClass && icm.outerClass().isPresent()
                                && this.name.equals(icm.innerClass().asInternalName())
                                && this.outerClassName == null) {
                            this.outerClassName = icm.outerClass().get().asInternalName();
                        }
                    }
                }
                case FieldModel fm -> {
                    if (isPublic(fm.flags())) {
                        fields.add(new Field(fm.flags().flagsMask(),
                                             fm.fieldName().stringValue(),
                                             fm.fieldType().stringValue()));
                    }
                }
                case MethodModel mm -> {
                    if (isPublic(mm.flags())) {
                        Set<String> exceptionSet = new HashSet<>();
                        mm.findAttribute(Attributes.exceptions()).ifPresent(ea ->
                                ea.exceptions().forEach(e ->
                                        exceptionSet.add(e.asInternalName())));
                        // treat type descriptor as a proxy for signature because signature
                        // is usually null, need to strip off the return type though
                        int n;
                        var desc = mm.methodType().stringValue();
                        if (desc != null && (n = desc.lastIndexOf(')')) != -1) {
                            desc = desc.substring(0, n + 1);
                            methods.add(new Method(mm.flags().flagsMask(),
                                    mm.methodName().stringValue(), desc, exceptionSet));
                        }
                    }
                }
                case EnclosingMethodAttribute ema -> {
                    if (this.maybeNestedClass) {
                        this.outerClassName = ema.enclosingClass().asInternalName();
                    }
                }
                default -> {}
            }
        }

        private static boolean isPublic(AccessFlags access) {
            return access.has(AccessFlag.PUBLIC) || access.has(AccessFlag.PROTECTED);
        }

        @Override
        public boolean equals(Object that) {
            if (that == null) return false;
            if (this == that) return true;
            if (!(that instanceof ClassAttributes)) return false;
            ClassAttributes clsAttrs = (ClassAttributes)that;
            boolean superNameOkay = superName != null
                    ? superName.equals(clsAttrs.superName) : true;
            return access == clsAttrs.access
                    && superNameOkay
                    && fields.equals(clsAttrs.fields)
                    && methods.equals(clsAttrs.methods);
        }

        @Override
        public int hashCode() {
            int result = 17;
            result = 37 * result + access;
            result = 37 * result + superName != null ? superName.hashCode() : 0;
            result = 37 * result + fields.hashCode();
            result = 37 * result + methods.hashCode();
            return result;
        }
    }
}
