package org.xbib.net.security.signatures;

import java.security.spec.AlgorithmParameterSpec;
import java.text.NumberFormat;
import java.text.ParseException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Date;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

public class Signature {

    /**
     * Regular expression pattern for fields present in the Authorization field.
     * Fields value may be double-quoted strings, e.g. algorithm="hs2019"
     * Some fields may be numerical values without double-quotes, e.g. created=123456
     */
    private static final Pattern RFC_2617_PARAM = Pattern
            .compile("(?<key>\\w+)=((\"(?<stringValue>[^\"]*)\")|(?<numberValue>\\d+\\.?\\d*))");
    /**
     * The maximum time skew between the client and the server.
     * This is used to validate the (created) and (expires) fields in the HTTP signature.
     */
    public static long maxTimeSkewInMilliseconds = 30 * 1000L;
    /**
     * REQUIRED.  The `keyId` field is an opaque string that the server can
     * use to look up the component they need to validate the signature.  It
     * could be an SSH key fingerprint, a URL to machine-readable key data,
     * an LDAP DN, etc.  Management of keys and assignment of `keyId` is out
     * of scope for this document.
     */
    private final String keyId;
    /**
     * RECOMMENDED.  The `signingAlgorithm` parameter is used to specify the digital
     * signature algorithm to use when generating the signature.  Valid
     * values for this parameter can be found in the Signature Algorithms
     * registry located at http://www.iana.org/assignments/signature-
     * algorithms and MUST NOT be marked "deprecated".
     * <p>
     * Verifiers MUST determine the signature's Algorithm from the keyId parameter
     * rather than from algorithm. If algorithm is provided and differs from or
     * is incompatible with the algorithm or key material identified by keyId
     * (for example, algorithm has a value of rsa-sha256 but keyId identifies
     * an EdDSA key), then implementations MUST produce an error.
     * <p>
     * https://datatracker.ietf.org/doc/draft-ietf-httpbis-message-signatures/
     */
    private final SigningAlgorithm signingAlgorithm;
    /**
     * REQUIRED.  The `algorithm` parameter is used to specify the digital
     * signature algorithm to use when generating the signature.  Valid
     * values for this parameter can be found in the Signature Algorithms
     * registry located at http://www.iana.org/assignments/signature-
     * algorithms and MUST NOT be marked "deprecated".
     */
    private final Algorithm algorithm;
    /**
     * REQUIRED.  The `signature` parameter is a base 64 encoded digital
     * signature, as described in RFC 4648 [RFC4648], Section 4 [4].  The
     * client uses the `algorithm` and `headers` signature parameters to
     * form a canonicalized `signing string`.  This `signing string` is then
     * signed with the key associated with `keyId` and the algorithm
     * corresponding to `algorithm`.  The `signature` parameter is then set
     * to the base 64 encoding of the signature.
     * <p>
     * Signing: this field is calculated from the input data.
     * Verification: this field is parsed from the signature field in the
     * Authorization header.
     */
    private final String signature;
    /**
     * OPTIONAL.  The `headers` parameter is used to specify the list of
     * HTTP headers included when generating the signature for the message.
     * If specified, it should be a lowercased, quoted list of HTTP header
     * fields, separated by a single space character.  If not specified,
     * implementations MUST operate as if the field were specified with a
     * single value, the `Date` header, in the list of HTTP headers.  Note
     * that the list order is important, and MUST be specified in the order
     * the HTTP header field-value pairs are concatenated together during
     * signing.
     */
    private final List<String> headers;
    /**
     * OPTIONAL.  The `parameterSpec` is used to specify the cryptographic
     * parameters. Some cryptographic algorithm such as RSASSA-PSS
     * require parameters.
     */
    private final AlgorithmParameterSpec parameterSpec;
    /**
     * OPTIONAL. The configurable signature's maximum validation duration, in milliseconds.
     * This field is applicable when the signed headers include '(expires)'.
     * In that case, the value of the '(expires)' field is calculated by adding
     * maxSignatureValidityDuration to the timestamp of the signature creation time.
     * <p>
     * This field is used to derive the signature expiration time when a cryptographic signature
     * is generated.
     */
    private final Long maxSignatureValidityDuration;
    /**
     * The signature's Creation Time, in milliseconds since the epoch.
     * <p>
     * This field is set at the time the cryptographic signature is generated, or when
     * the 'Authorization' header is parsed.
     */
    private final Long signatureCreatedTime;
    /**
     * The signature's Expiration Time, in milliseconds since the epoch.
     * <p>
     * This field is set at the time the cryptographic signature is generated, or when
     * the 'Authorization' header is parsed.
     */
    private final Long signatureExpiresTime;

    /**
     * Construct a signature configuration instance with the specified keyId, algorithm and HTTP headers.
     *
     * @param keyId            An opaque string that the server can use to look up the component they need to validate the signature.
     * @param signingAlgorithm An identifier for the HTTP Signature algorithm.
     *                         This should be "hs2019" except for legacy applications that use an older version of the draft HTTP signature specification.
     * @param algorithm        The detailed algorithm used to sign the message.
     * @param parameterSpec    optional cryptographic parameters for the signature.
     * @param headers          The list of HTTP headers that will be used in the signature.
     */
    public Signature(final String keyId, final String signingAlgorithm, final String algorithm, final AlgorithmParameterSpec parameterSpec, final List<String> headers) {
        this(keyId, getSigningAlgorithm(signingAlgorithm), getAlgorithm(algorithm), parameterSpec, null, headers);
    }

    public Signature(final String keyId, final String signingAlgorithm, final String algorithm,
                     final AlgorithmParameterSpec parameterSpec, final String signature, final List<String> headers) {
        this(keyId, getSigningAlgorithm(signingAlgorithm), getAlgorithm(algorithm), parameterSpec, signature, headers);
    }

    public Signature(final String keyId, final SigningAlgorithm signingAlgorithm, final Algorithm algorithm,
                     final AlgorithmParameterSpec parameterSpec, final String signature, final List<String> headers) {
        this(keyId, signingAlgorithm, algorithm, parameterSpec, signature, headers, null);
    }

    public Signature(final String keyId, final SigningAlgorithm signingAlgorithm, final Algorithm algorithm,
                     final AlgorithmParameterSpec parameterSpec, final String signature,
                     final List<String> headers, final Long maxSignatureValidityDuration) {
        this(keyId, signingAlgorithm, algorithm, parameterSpec, signature, headers, maxSignatureValidityDuration, null, null);
    }

    public Signature(final String keyId, final SigningAlgorithm signingAlgorithm, final Algorithm algorithm,
                     final AlgorithmParameterSpec parameterSpec, final String signature,
                     final List<String> headers, final Long maxSignatureValidityDuration,
                     final Long signatureCreatedTime, final Long signatureExpiresTime) {
        if (keyId == null || keyId.trim().isEmpty()) {
            throw new IllegalArgumentException("keyId is required.");
        }
        if (algorithm == null) {
            throw new IllegalArgumentException("algorithm is required.");
        }
        if (signingAlgorithm != null &&
                signingAlgorithm.getSupportedAlgorithms() != null &&
                !signingAlgorithm.getSupportedAlgorithms().contains(algorithm)) {
            throw new IllegalArgumentException("Signing algorithm " + signingAlgorithm.getAlgorithmName() +
                    " is not compatible with " + algorithm.getPortableName());
        }

        this.keyId = keyId;
        this.signingAlgorithm = signingAlgorithm;
        this.algorithm = algorithm;
        if (maxSignatureValidityDuration != null && maxSignatureValidityDuration <= 0) {
            throw new IllegalArgumentException("Signature max validity must be a positive number");
        }
        this.maxSignatureValidityDuration = maxSignatureValidityDuration;
        this.signatureCreatedTime = signatureCreatedTime;
        this.signatureExpiresTime = signatureExpiresTime;
        this.signature = signature;
        this.parameterSpec = parameterSpec;
        if (headers == null || headers.size() == 0) {
            this.headers = List.of("date");
        } else {
            this.headers = Collections.unmodifiableList(lowercase(headers));
        }
    }

    private static Algorithm getAlgorithm(final String algorithm) {
        if (algorithm == null) throw new IllegalArgumentException("Algorithm cannot be null");
        return Algorithm.get(algorithm);
    }

    private static SigningAlgorithm getSigningAlgorithm(final String scheme) {
        if (scheme == null) {
            throw new IllegalArgumentException("Signing scheme cannot be null");
        }
        return SigningAlgorithm.get(scheme);
    }

    /**
     * Constructs a Signature object by parsing the 'Authorization' header.
     * <p>
     * As stated in the HTTP signature specification, the value of the algorithm parameter in
     * the 'Authorization' header should be set to generic identifier. The detailed algorithm
     * should be derived from the keyId. Hence it is not possible to determine the detailed
     * algorithm by inspecting the signature data.
     *
     * @param authorization The value of the HTTP 'Authorization' header containing the signature data.
     * @param algorithm     The detailed cryptographic algorithm for the HTTP signature.
     * @return The Signature object.
     */
    public static Signature fromString(String authorization, final Algorithm algorithm) {
        /*
         * A HTTP signature field value in the authorization header.
         */
        class FieldValue {
            /**
             * The field value. It may be a string or number.
             */
            private final Object value;
            /**
             * A flag indicating whether the field is a string or number.
             */
            private final boolean isNumber;

            FieldValue(final String value, final boolean isNumber) throws ParseException {
                this.isNumber = isNumber;
                if (isNumber) {
                    this.value = NumberFormat.getInstance().parse(value);
                } else {
                    this.value = value;
                }
            }

            /** Returns true if the field is a string */
            boolean isString() {
                return !isNumber;
            }

            /** Returns true if the field is a number */
            boolean isNumber() {
                return isNumber;
            }

            /** Returns true if the field is an integer value */
            boolean isInteger() {
                return value instanceof Long;
            }

            /** Returns the field as a string, or null if the field is not a string. */
            String getValueAsString() {
                if (!isString()) return null;
                return (String) value;
            }

            /** Returns the field as a long value, or null if the field is not a integer number. */
            Long getValueAsLong() {
                if (!isInteger()) return null;
                return ((Number) value).longValue();
            }

            /** Returns the field as a double value, or null if the field is not a number. */
            Double getValueAsDouble() {
                if (!isNumber()) return null;
                return ((Number) value).doubleValue();
            }
        }

        try {
            authorization = normalize(authorization);

            final Map<String, FieldValue> map = new HashMap<>();

            final Matcher matcher = RFC_2617_PARAM.matcher(authorization);
            while (matcher.find()) {
                final String key = matcher.group("key").toLowerCase();
                boolean isNumber = false;
                String value = matcher.group("stringValue");
                if (value == null) {
                    value = matcher.group("numberValue");
                    isNumber = true;
                }
                map.put(key, new FieldValue(value, isNumber));
            }

            final List<String> headers = new ArrayList<String>();
            FieldValue fieldValue = map.get("headers");
            if (fieldValue != null) {
                if (!fieldValue.isString()) {
                    throw new IllegalArgumentException("headers field must be a double-quoted string");
                }
                Collections.addAll(headers, fieldValue.getValueAsString().toLowerCase().split(" +"));
            }
            String keyid = null;
            fieldValue = map.get("keyid");
            if (fieldValue != null && fieldValue.isString()) {
                keyid = fieldValue.getValueAsString();
            }
            if (keyid == null) {
                throw new MissingKeyIdException();
            }
            String algorithmField = null;
            fieldValue = map.get("algorithm");
            if (fieldValue != null && fieldValue.isString()) {
                algorithmField = fieldValue.getValueAsString();
            }
            if (algorithmField == null) {
                throw new MissingAlgorithmException();
            }
            String signature = null;
            fieldValue = map.get("signature");
            if (fieldValue != null && fieldValue.isString()) {
                signature = fieldValue.getValueAsString();
            }
            if (signature == null) {
                throw new MissingSignatureException();
            }
            Long created = null;
            fieldValue = map.get("created");
            if (fieldValue != null) {
                if (!fieldValue.isInteger()) {
                    throw new InvalidCreatedFieldException("Field must be an integer value");
                }
                created = fieldValue.getValueAsLong() * 1000L;
            }
            Long expires = null; // The signature expiration time, in milliseconds since the epoch.
            fieldValue = map.get("expires");
            if (fieldValue != null) {
                if (!fieldValue.isNumber()) {
                    throw new InvalidExpiresFieldException("Field must be a number");
                }
                expires = (long) (fieldValue.getValueAsDouble() * 1000L);
            }
            SigningAlgorithm parsedSigningAlgorithm = null;
            try {
                parsedSigningAlgorithm = SigningAlgorithm.get(algorithmField);
            } catch (final UnsupportedAlgorithmException ex) {
                // This may happen for older implementations that pass the serialize the detailed
                // algorithm instead of using 'hs2019'. In that case, the value of 'algorithm'
                // should be one of the supported values in the Algorithm enum. If not, an
                // exception is raised.
            }
            Algorithm parsedAlgorithm = null;
            try {
                parsedAlgorithm = Algorithm.get(algorithmField);
                if (algorithm != null && parsedAlgorithm.getPortableName() != algorithm.getPortableName()) {
                    throw new IllegalArgumentException("The algorithm does not match the value of the 'Authorization' header.");
                }
            } catch (final UnsupportedAlgorithmException ex) {
                // This is expected for new conformant implementations that set the algorithm
                // field in the 'Authorization' header to 'hs2019'. The algorithm must be
                // derived from the keyId. The client is responsible for maintaining the
                // mapping between the keyId and the detailed cryptographic algorithm.
                if (algorithm == null) {
                    throw new IllegalArgumentException("The algorithm is required.");
                }
                parsedAlgorithm = algorithm;
            }
            final Signature s = new Signature(keyid, parsedSigningAlgorithm, parsedAlgorithm, null, signature, headers, null, created, expires);
            s.verifySignatureValidityDates();
            return s;

        } catch (final AuthenticationException e) {
            throw e;
        } catch (final Throwable e) {
            throw new UnparsableSignatureException(authorization, e);
        }
    }

    public static Signature fromString(String authorization) {
        return fromString(authorization, null);
    }

    private static String normalize(String authorization) {
        final String start = "signature ";
        final String prefix = authorization.substring(0, start.length()).toLowerCase();
        if (prefix.equals(start)) {
            authorization = authorization.substring(start.length());
        }
        return authorization.trim();
    }

    /**
     * Returns the signature creation time.
     *
     * @return the signature creation time.
     */
    public Date getSignatureCreation() {
        if (signatureCreatedTime == null) return null;
        return new Date(signatureCreatedTime);
    }

    /**
     * Returns the signature creation time in milliseconds since the epoch.
     *
     * @return the signature creation time in milliseconds since the epoch.
     */
    public Long getSignatureCreationTimeMilliseconds() {
        return signatureCreatedTime;
    }

    /**
     * Returns the signature max validity duration, in milliseconds.
     *
     * @return the signature max validity duration, in milliseconds.
     */
    public Long getSignatureMaxValidityMilliseconds() {
        return maxSignatureValidityDuration;
    }

    /**
     * Returns the signature expiration time.
     *
     * @return the signature expiration time.
     */
    public Date getSignatureExpiration() {
        if (signatureExpiresTime == null) return null;
        return new Date(signatureExpiresTime);
    }

    /**
     * Returns the signature expiration time in milliseconds since the epoch.
     *
     * @return the signature expiration time in milliseconds since the epoch.
     */
    public Long getSignatureExpirationTimeMilliseconds() {
        return signatureExpiresTime;
    }

    private List<String> lowercase(final List<String> headers) {
        final List<String> list = new ArrayList<String>(headers.size());
        for (final String header : headers) {
            list.add(header.toLowerCase());
        }
        return list;
    }

    public String getKeyId() {
        return keyId;
    }

    /**
     * Returns the detailed implementation algorithm for HTTP signatures.
     *
     * @return the cryptographic algorithm.
     */
    public Algorithm getAlgorithm() {
        return algorithm;
    }

    /**
     * Returns the identifier for the HTTP Signature Algorithm, as registered
     * in the HTTP Signature Algorithms Registry.
     *
     * @return the identifier for the HTTP Signature Algorithm.
     */
    public SigningAlgorithm getSigningAlgorithm() {
        return signingAlgorithm;
    }

    /**
     * Returns the base-64 encoded value of the signature.
     *
     * @return the base-64 encoded value of the signature.
     */
    public String getSignature() {
        return signature;
    }

    /**
     * Returns the specification of cryptographic parameters.
     *
     * @return specification of cryptographic parameters.
     */
    public AlgorithmParameterSpec getParameterSpec() {
        return parameterSpec;
    }

    public List<String> getHeaders() {
        return headers;
    }

    /**
     * Verify the signature is valid with regards to the (created) and (expires) fields.
     * <p>
     * When the '(created)' field is present in the HTTP signature, the '(created)' field
     * represents the date when the signature has been created.
     * When the '(expires)' field is present in the HTTP signature, the '(expires)' field
     * represents the date when the signature expires.
     */
    public void verifySignatureValidityDates() {
        if (signatureCreatedTime != null && signatureCreatedTime > System.currentTimeMillis() + maxTimeSkewInMilliseconds) {
            throw new InvalidCreatedFieldException("Signature is not valid yet");
        }
        if (signatureExpiresTime != null && signatureExpiresTime < System.currentTimeMillis()) {
            throw new InvalidExpiresFieldException("Signature has expired");
        }
    }

    @Override
    public String toString() {
        return toString("Signature");
    }

    /**
     * Returns the formatted signature parameters without any "Signature " prefix
     */
    public String toParamString() {
        return toString(null);
    }

    public String toString(final String prefix) {
        if (SigningAlgorithm.HS2019.equals(signingAlgorithm)) {
            // When the signing algorithm is set to 'hs2019', the value of the algorithm
            // field must be set to 'hs2019'. The specific crypto algorithm is not
            // serialized in the 'Authorization' header, the server must derive the value
            // from the keyId.
            return (prefix != null ? prefix + " " : "") +
                    "keyId=\"" + keyId + '\"' +
                    (signatureCreatedTime != null ? String.format(",created=%d", signatureCreatedTime / 1000L) : "") +
                    (signatureExpiresTime != null ? String.format(",expires=%.3f", signatureExpiresTime / 1000.0) : "") +
                    ",algorithm=\"" + signingAlgorithm + '\"' +
                    ",headers=\"" + Joiner.join(" ", headers) + '\"' +
                    ",signature=\"" + signature + '\"';
        } else {
            return (prefix != null ? prefix + " " : "") +
                    "keyId=\"" + keyId + '\"' +
                    ",algorithm=\"" + algorithm + '\"' +
                    ",headers=\"" + Joiner.join(" ", headers) + '\"' +
                    ",signature=\"" + signature + '\"';
        }
    }
}
