/*
 * Licensed to the Apache Software Foundation (ASF) under one
 * or more contributor license agreements. See the NOTICE file
 * distributed with this work for additional information
 * regarding copyright ownership. The ASF licenses this file
 * to you 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 org.apache.sshd.client.config.hosts;

import java.io.IOException;
import java.io.UncheckedIOException;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import java.util.Base64;
import java.util.Objects;

import org.apache.sshd.common.Factory;
import org.apache.sshd.common.NamedFactory;
import org.apache.sshd.common.NamedResource;
import org.apache.sshd.common.RuntimeSshException;
import org.apache.sshd.common.SshConstants;
import org.apache.sshd.common.mac.Mac;
import org.apache.sshd.common.util.GenericUtils;
import org.apache.sshd.common.util.NumberUtils;
import org.apache.sshd.common.util.ValidateUtils;
import org.apache.sshd.common.util.buffer.BufferUtils;

/**
 * @author <a href="mailto:dev@mina.apache.org">Apache MINA SSHD Project</a>
 */
public class KnownHostHashValue {
    /**
     * Character used to indicate a hashed host pattern
     */
    public static final char HASHED_HOST_DELIMITER = '|';

    public static final NamedFactory<Mac> DEFAULT_DIGEST = KnownHostDigest.SHA1;

    private NamedFactory<Mac> digester = DEFAULT_DIGEST;
    private byte[] saltValue;
    private byte[] digestValue;

    public KnownHostHashValue() {
        super();
    }

    public NamedFactory<Mac> getDigester() {
        return digester;
    }

    public void setDigester(NamedFactory<Mac> digester) {
        this.digester = digester;
    }

    public byte[] getSaltValue() {
        return saltValue;
    }

    public void setSaltValue(byte[] saltValue) {
        this.saltValue = saltValue;
    }

    public byte[] getDigestValue() {
        return digestValue;
    }

    public void setDigestValue(byte[] digestValue) {
        this.digestValue = digestValue;
    }

    /**
     * Checks if the host matches the hash
     *
     * @param  host             The host name/address - ignored if {@code null}/empty
     * @param  port             The access port - ignored if non-positive or SSH default
     * @return                  {@code true} if host matches the hash
     * @throws RuntimeException If entry not properly initialized
     */
    public boolean isHostMatch(String host, int port) {
        if (GenericUtils.isEmpty(host)) {
            return false;
        }

        try {
            byte[] expected = getDigestValue();
            byte[] actual = calculateHashValue(host, port, getDigester(), getSaltValue());
            return Arrays.equals(expected, actual);
        } catch (RuntimeException e) {
            throw e;
        } catch (Throwable t) {
            throw new RuntimeSshException(
                    "Failed (" + t.getClass().getSimpleName() + ")" + " to calculate hash value: " + t.getMessage(), t);
        }
    }

    @Override
    public String toString() {
        if ((getDigester() == null) || NumberUtils.isEmpty(getSaltValue()) || NumberUtils.isEmpty(getDigestValue())) {
            return Objects.toString(getDigester(), null)
                   + "-" + BufferUtils.toHex(':', getSaltValue())
                   + "-" + BufferUtils.toHex(':', getDigestValue());
        }

        try {
            return append(new StringBuilder(Byte.MAX_VALUE), this).toString();
        } catch (IOException | RuntimeException e) { // unexpected
            return e.getClass().getSimpleName() + ": " + e.getMessage();
        }
    }

    // see http://nms.lcs.mit.edu/projects/ssh/README.hashed-hosts
    public static byte[] calculateHashValue(String host, int port, Factory<? extends Mac> factory, byte[] salt)
            throws Exception {
        return calculateHashValue(host, port, factory.create(), salt);
    }

    public static byte[] calculateHashValue(String host, int port, Mac mac, byte[] salt) throws Exception {
        mac.init(salt);

        String hostPattern = createHostPattern(host, port);
        byte[] hostBytes = hostPattern.getBytes(StandardCharsets.UTF_8);
        mac.update(hostBytes);
        return mac.doFinal();
    }

    public static String createHostPattern(String host, int port) {
        if (SshConstants.TO_EFFECTIVE_PORT.applyAsInt(port) == SshConstants.DEFAULT_PORT) {
            return host;
        }

        try {
            return appendHostPattern(new StringBuilder(host.length() + 8 /* port if necessary */), host, port).toString();
        } catch (IOException e) {
            // StringBuilder does not throw IOException so this can't happen
            throw new UncheckedIOException("Unexpected (" + e.getClass().getSimpleName() + ") failure"
                                           + " to generate host pattern of " + host + ":"
                                           + port + ": " + e.getMessage(),
                    e);
        }
    }

    public static <A extends Appendable> A appendHostPattern(A sb, String host, int port) throws IOException {
        boolean nonDefaultPort = SshConstants.TO_EFFECTIVE_PORT.applyAsInt(port) != SshConstants.DEFAULT_PORT;
        if (nonDefaultPort) {
            sb.append(HostPatternsHolder.NON_STANDARD_PORT_PATTERN_ENCLOSURE_START_DELIM);
        }
        sb.append(host);
        if (nonDefaultPort) {
            sb.append(HostPatternsHolder.NON_STANDARD_PORT_PATTERN_ENCLOSURE_END_DELIM);
            sb.append(HostPatternsHolder.PORT_VALUE_DELIMITER);
            sb.append(Integer.toString(port));
        }
        return sb;
    }

    public static <A extends Appendable> A append(A sb, KnownHostHashValue hashValue) throws IOException {
        return (hashValue == null)
                ? sb : append(sb, hashValue.getDigester(), hashValue.getSaltValue(), hashValue.getDigestValue());
    }

    public static <A extends Appendable> A append(A sb, NamedResource factory, byte[] salt, byte[] digest) throws IOException {
        Base64.Encoder encoder = Base64.getEncoder();
        sb.append(HASHED_HOST_DELIMITER).append(factory.getName());
        sb.append(HASHED_HOST_DELIMITER).append(encoder.encodeToString(salt));
        sb.append(HASHED_HOST_DELIMITER).append(encoder.encodeToString(digest));
        return sb;
    }

    public static KnownHostHashValue parse(String patternString) {
        String pattern = GenericUtils.replaceWhitespaceAndTrim(patternString);
        return parse(pattern, GenericUtils.isEmpty(pattern) ? null : new KnownHostHashValue());
    }

    public static <V extends KnownHostHashValue> V parse(String patternString, V value) {
        String pattern = GenericUtils.replaceWhitespaceAndTrim(patternString);
        if (GenericUtils.isEmpty(pattern)) {
            return value;
        }

        String[] components = GenericUtils.split(pattern, HASHED_HOST_DELIMITER);
        ValidateUtils.checkTrue(components.length == 4 /* 1st one is empty */, "Invalid hash pattern (insufficient data): %s",
                pattern);
        ValidateUtils.checkTrue(GenericUtils.isEmpty(components[0]), "Invalid hash pattern (unexpected extra data): %s",
                pattern);

        NamedFactory<Mac> factory = ValidateUtils.checkNotNull(KnownHostDigest.fromName(components[1]),
                "Invalid hash pattern (unknown digest): %s", pattern);
        Base64.Decoder decoder = Base64.getDecoder();
        value.setDigester(factory);
        value.setSaltValue(decoder.decode(components[2]));
        value.setDigestValue(decoder.decode(components[3]));
        return value;
    }
}
