/****************************************************************
 * 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.mailet;

import java.util.Arrays;
import java.util.EnumSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.function.Consumer;

import javax.mail.internet.AddressException;

import org.apache.commons.lang3.tuple.Pair;
import org.apache.james.core.MailAddress;

import com.github.fge.lambdas.Throwing;
import com.google.common.base.CharMatcher;
import com.google.common.base.Joiner;
import com.google.common.base.MoreObjects;
import com.google.common.base.Preconditions;
import com.google.common.base.Splitter;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableMap;
import com.google.common.collect.ImmutableSet;

/**
 * Represents DSN parameters attached to the envelope of an Email transiting over SMTP
 *
 * See https://tools.ietf.org/html/rfc3461
 */
public class DsnParameters {
    public static final String NOTIFY_PARAMETER = "NOTIFY";
    public static final String RFC_822_PREFIX = "rfc822;";
    public static final String ORCPT_PARAMETER = "ORCPT";
    public static final String ENVID_PARAMETER = "ENVID";
    public static final String RET_PARAMETER = "RET";

    public static class XText {
        private static final CharMatcher ESCAPE_CHAR = CharMatcher.isNot('+');
        // https://datatracker.ietf.org/doc/html/rfc3461#section-4
        private static CharMatcher XCHAR_VALIDATOR = CharMatcher.inRange('!', '~')
            .and(CharMatcher.isNot('='))
            .and(ESCAPE_CHAR);
        private static CharMatcher HEX_VALIDATOR = CharMatcher.inRange('0', '9')
            .or(CharMatcher.inRange('A', 'F'));

        public static String encode(String text) {
            if (isOnlyXChar(text)) {
                return text;
            }
            StringBuilder result = new StringBuilder();
            text.chars()
                .forEach(i -> encode(i, result));
            return result.toString();
        }

        private static StringBuilder encode(int i, StringBuilder result) {
            if (i < 33 || i > 126 || i == 43 || i == 61) {
                return result.append('+')
                    .append(String.format("%02X", i));
            }
            return result.append((char) i);
        }

        public static String decode(String text) {
            Preconditions.checkArgument(isValid(text), "Decoding invalid xtext '%s'", text);
            if (XCHAR_VALIDATOR.matchesAllOf(text)) {
                return text;
            }
            char[] chars = text.toCharArray();
            StringBuilder result = new StringBuilder();
            int i = 0;
            while (i < chars.length) {
                char c = chars[i];
                if (c == 43) {
                    // + escape sequence
                    char hex1 = chars[i + 1];
                    char hex2 = chars[i + 2];
                    char hexChar = (char) Integer.parseInt("" + hex1 + hex2, 16);
                    result.append(hexChar);
                    i += 3;
                } else {
                    result.append(c);
                    i++;
                }
            }
            return result.toString();
        }

        public static boolean isValid(String text) {
            if (isOnlyXChar(text)) {
                return true;
            }
            char[] chars = text.toCharArray();
            int i = 0;
            while (i < chars.length) {
                char c = chars[i];
                if (!XCHAR_VALIDATOR.matches(c)) {
                    if (c == 43) {
                        // + escape sequence
                        if (!isValidEscapeSequence(chars, i)) {
                            return false;
                        }
                        i += 3;
                    } else {
                        return false;
                    }
                } else {
                    i++;
                }
            }
            return true;
        }

        private static boolean isValidEscapeSequence(char[] chars, int i) {
            if (i + 3 > chars.length) {
                return false;
            }
            return HEX_VALIDATOR.matches(chars[i + 1])
                && HEX_VALIDATOR.matches(chars[i + 2]);
        }

        private static boolean isOnlyXChar(String text) {
            return XCHAR_VALIDATOR.matchesAllOf(text);
        }
    }

    /**
     * RET parameter allow the sender to control which part of the bounced message should be returned to the sender.
     *
     * Either headers or full.
     *
     * https://tools.ietf.org/html/rfc3461#section-4.3
     */
    public enum Ret {
        FULL,
        HDRS;

        public static Optional<Ret> fromSMTPArgLine(Map<String, String> mailFromArgLine) {
            return Optional.ofNullable(mailFromArgLine.get(RET_PARAMETER))
                .map(input -> parse(input)
                    .orElseThrow(() -> new IllegalArgumentException(input + " is not a supported value for RET DSN parameter")));
        }

        public static Ret fromAttributeValue(AttributeValue<String> attributeValue) {
            return parse(attributeValue.value())
                .orElseThrow(() -> new IllegalArgumentException(attributeValue.value() + " is not a supported value for RET DSN parameter"));
        }

        public static AttributeValue<String> toAttributeValue(Ret value) {
            return AttributeValue.of(value.toString());
        }

        public static Optional<Ret> parse(String string) {
            Preconditions.checkNotNull(string);

            return Arrays.stream(Ret.values())
                .filter(value -> value.toString().equalsIgnoreCase(string))
                .findAny();
        }
    }

    /**
     * ENVID allow the sender to correlate a bounce with a submission.
     *
     * https://tools.ietf.org/html/rfc3461#section-4.4
     */
    public static class EnvId {
        public static Optional<EnvId> fromSMTPArgLine(Map<String, String> mailFromArgLine) {
            return Optional.ofNullable(mailFromArgLine.get(ENVID_PARAMETER))
                .map(EnvId::of);
        }

        public static EnvId fromAttributeValue(AttributeValue<String> attributeValue) {
            return of(attributeValue.value());
        }

        public static EnvId of(String value) {
            Preconditions.checkNotNull(value);
            Preconditions.checkArgument(XText.isValid(value), "According to RFC-3461 EnvId should be a valid xtext" +
                ", thus composed of CHARs between \"!\" (33) and \"~\" (126) inclusive, except for \"+\" and \"=\" or follow the hexadecimal escape sequence.");

            return new EnvId(value);
        }

        private final String value;

        private EnvId(String value) {
            this.value = value;
        }

        public String asString() {
            return value;
        }

        public AttributeValue<String> toAttributeValue() {
            return AttributeValue.of(value);
        }

        @Override
        public final boolean equals(Object o) {
            if (o instanceof EnvId) {
                EnvId that = (EnvId) o;

                return Objects.equals(this.value, that.value);
            }
            return false;
        }

        @Override
        public final int hashCode() {
            return Objects.hash(value);
        }

        @Override
        public String toString() {
            return MoreObjects.toStringHelper(this)
                .add("value", value)
                .toString();
        }
    }

    /**
     * NOTIFY controls in which situations a bounce should be emited for a given recipient.
     *
     * https://tools.ietf.org/html/rfc3461#section-4.1
     */
    public enum Notify {
        NEVER,
        SUCCESS,
        FAILURE,
        DELAY;

        public static EnumSet<Notify> fromAttributeValue(AttributeValue<String> attributeValue) {
            return parse(attributeValue.value());
        }

        public static AttributeValue<String> toAttributeValue(EnumSet<Notify> value) {
            return AttributeValue.of(Joiner.on(',').join(value));
        }

        public static EnumSet<Notify> parse(String input) {
            Preconditions.checkNotNull(input);

            return validate(EnumSet.copyOf(Splitter.on(",")
                .omitEmptyStrings()
                .trimResults()
                .splitToStream(input)
                .map(string -> parseValue(string)
                    .orElseThrow(() -> new IllegalArgumentException(string + " could not be associated with any RCPT NOTIFY value")))
                .collect(ImmutableList.toImmutableList())));
        }

        public static Optional<Notify> parseValue(String input) {
            return Arrays.stream(Notify.values())
                .filter(value -> value.toString().equalsIgnoreCase(input))
                .findAny();
        }

        private static EnumSet<Notify> validate(EnumSet<Notify> input) {
            Preconditions.checkArgument(!input.contains(NEVER) || input.size() == 1,
                "RCPT Notify should not contain over values when containing never");

            return input;
        }
    }

    /**
     * Holds NOTIFY and ORCPT parameters for a specific recipient.
     */
    public static class RecipientDsnParameters {
        public static Optional<RecipientDsnParameters> fromSMTPArgLine(Map<String, String> rcptToArgLine) {
            Optional<EnumSet<Notify>> notifyParameter = Optional.ofNullable(rcptToArgLine.get(NOTIFY_PARAMETER))
                .map(Notify::parse);
            Optional<MailAddress> orcptParameter = Optional.ofNullable(rcptToArgLine.get(ORCPT_PARAMETER))
                .map(RecipientDsnParameters::parseOrcpt);

            return of(notifyParameter, orcptParameter);
        }

        public static Optional<RecipientDsnParameters> of(Optional<EnumSet<Notify>> notifyParameter, Optional<MailAddress> orcptParameter) {
            if (notifyParameter.isEmpty() && orcptParameter.isEmpty()) {
                return Optional.empty();
            }
            return Optional.of(new RecipientDsnParameters(notifyParameter, orcptParameter));
        }

        public static RecipientDsnParameters of(EnumSet<Notify> notifyParameter, MailAddress orcptParameter) {
            return new RecipientDsnParameters(Optional.of(notifyParameter), Optional.of(orcptParameter));
        }

        public static RecipientDsnParameters of(MailAddress orcptParameter) {
            return new RecipientDsnParameters(Optional.empty(), Optional.of(orcptParameter));
        }

        public static RecipientDsnParameters of(EnumSet<Notify> notifyParameter) {
            return new RecipientDsnParameters(Optional.of(notifyParameter), Optional.empty());
        }

        private static MailAddress parseOrcpt(String input) {
            Preconditions.checkArgument(input.startsWith(RFC_822_PREFIX), "ORCPT must start with the rfc822 prefix");
            String addressPart = input.substring(RFC_822_PREFIX.length());
            try {
                return new MailAddress(addressPart);
            } catch (AddressException e) {
                throw new IllegalArgumentException(addressPart + " could not be parsed", e);
            }
        }

        private final Optional<EnumSet<Notify>> notifyParameter;
        private final Optional<MailAddress> orcptParameter;

        RecipientDsnParameters(Optional<EnumSet<Notify>> notifyParameter, Optional<MailAddress> orcptParameter) {
            this.notifyParameter = notifyParameter;
            this.orcptParameter = orcptParameter;
        }

        public Optional<EnumSet<Notify>> getNotifyParameter() {
            return notifyParameter;
        }

        public Optional<MailAddress> getOrcptParameter() {
            return orcptParameter;
        }

        @Override
        public final boolean equals(Object o) {
            if (o instanceof RecipientDsnParameters) {
                RecipientDsnParameters that = (RecipientDsnParameters) o;

                return Objects.equals(this.notifyParameter, that.notifyParameter)
                    && Objects.equals(this.orcptParameter, that.orcptParameter);
            }
            return false;
        }

        @Override
        public final int hashCode() {
            return Objects.hash(notifyParameter, orcptParameter);
        }

        @Override
        public String toString() {
            return MoreObjects.toStringHelper(this)
                .add("notifyParameter", notifyParameter)
                .add("orcptParameter", orcptParameter)
                .toString();
        }
    }

    public static class DsnAttributeValues {
        private static final AttributeName ENVID_ATTRIBUTE_NAME = AttributeName.of("dsn-envid");
        private static final AttributeName RET_ATTRIBUTE_NAME = AttributeName.of("dsn-ret");
        private static final AttributeName NOTIFY_ATTRIBUTE_NAME = AttributeName.of("dsn-notify");
        private static final AttributeName ORCPT_ATTRIBUTE_NAME = AttributeName.of("dsn-orcpt");

        public static DsnAttributeValues extract(Map<AttributeName, Attribute> attributesMap) {
            Optional<AttributeValue<String>> envId = Optional.ofNullable(attributesMap.get(ENVID_ATTRIBUTE_NAME))
                .flatMap(attribute -> attribute.getValue().asAttributeValueOf(String.class));
            Optional<AttributeValue<String>> ret = Optional.ofNullable(attributesMap.get(RET_ATTRIBUTE_NAME))
                .flatMap(attribute -> attribute.getValue().asAttributeValueOf(String.class));
            Optional<AttributeValue<Map<String, AttributeValue<String>>>> notify =
                Optional.ofNullable(attributesMap.get(NOTIFY_ATTRIBUTE_NAME))
                    .flatMap(attribute -> attribute.getValue().asMapAttributeValueOf(String.class));
            Optional<AttributeValue<Map<String, AttributeValue<String>>>> orcpt =
                Optional.ofNullable(attributesMap.get(ORCPT_ATTRIBUTE_NAME))
                    .flatMap(attribute -> attribute.getValue().asMapAttributeValueOf(String.class));

            return new DsnAttributeValues(notify, orcpt, envId, ret);
        }

        public static void forEachDsnAttributeName(Consumer<AttributeName> action) {
            action.accept(ENVID_ATTRIBUTE_NAME);
            action.accept(RET_ATTRIBUTE_NAME);
            action.accept(NOTIFY_ATTRIBUTE_NAME);
            action.accept(ORCPT_ATTRIBUTE_NAME);
        }

        private final Optional<AttributeValue<Map<String, AttributeValue<String>>>> notifyAttributeValue;
        private final Optional<AttributeValue<Map<String, AttributeValue<String>>>> orcptAttributeValue;
        private final Optional<AttributeValue<String>> envIdAttributeValue;
        private final Optional<AttributeValue<String>> retAttributeValue;

        public DsnAttributeValues(Optional<AttributeValue<Map<String, AttributeValue<String>>>> notifyAttributeValue, Optional<AttributeValue<Map<String, AttributeValue<String>>>> orcptAttributeValue, Optional<AttributeValue<String>> envIdAttributeValue, Optional<AttributeValue<String>> retAttributeValue) {
            this.notifyAttributeValue = notifyAttributeValue;
            this.orcptAttributeValue = orcptAttributeValue;
            this.envIdAttributeValue = envIdAttributeValue;
            this.retAttributeValue = retAttributeValue;
        }

        public Optional<AttributeValue<Map<String, AttributeValue<String>>>> getNotifyAttributeValue() {
            return notifyAttributeValue;
        }

        public Optional<AttributeValue<Map<String, AttributeValue<String>>>> getOrcptAttributeValue() {
            return orcptAttributeValue;
        }

        public Optional<AttributeValue<String>> getEnvIdAttributeValue() {
            return envIdAttributeValue;
        }

        public Optional<AttributeValue<String>> getRetAttributeValue() {
            return retAttributeValue;
        }

        public List<Attribute> asAttributes() {
            ImmutableList.Builder<Attribute> result = ImmutableList.builder();
            envIdAttributeValue.map(value -> new Attribute(ENVID_ATTRIBUTE_NAME, value)).ifPresent(result::add);
            retAttributeValue.map(value -> new Attribute(RET_ATTRIBUTE_NAME, value)).ifPresent(result::add);
            notifyAttributeValue.map(value -> new Attribute(NOTIFY_ATTRIBUTE_NAME, value)).ifPresent(result::add);
            orcptAttributeValue.map(value -> new Attribute(ORCPT_ATTRIBUTE_NAME, value)).ifPresent(result::add);
            return result.build();
        }
    }

    public static class Builder {
        private Optional<EnvId> envIdParameter;
        private Optional<Ret> retParameter;
        private final ImmutableMap.Builder<MailAddress, RecipientDsnParameters> rcptParameters;

        public Builder() {
            this.envIdParameter = Optional.empty();
            this.retParameter = Optional.empty();
            this.rcptParameters = ImmutableMap.builder();
        }

        public Builder envId(EnvId envId) {
            this.envIdParameter = Optional.of(envId);
            return this;
        }

        public Builder ret(Ret ret) {
            this.retParameter = Optional.of(ret);
            return this;
        }

        public Builder rcptParameters(ImmutableMap<MailAddress, RecipientDsnParameters> rcptParameters) {
            this.rcptParameters.putAll(rcptParameters);
            return this;
        }

        public Builder addRcptParameter(MailAddress recipient, RecipientDsnParameters parameters) {
            this.rcptParameters.put(recipient, parameters);
            return this;
        }

        public Optional<DsnParameters> build() {
            return of(envIdParameter, retParameter, rcptParameters.build());
        }
    }

    public static Builder builder() {
        return new Builder();
    }

    public static Optional<DsnParameters> of(Optional<EnvId> envIdParameter, Optional<Ret> retParameter, ImmutableMap<MailAddress, RecipientDsnParameters> rcptParameters) {
        if (envIdParameter.isEmpty() && retParameter.isEmpty() && rcptParameters.isEmpty()) {
            return Optional.empty();
        }
        return Optional.of(new DsnParameters(envIdParameter, retParameter, rcptParameters));
    }

    public static Optional<DsnParameters> fromAttributeValue(DsnAttributeValues dsnAttributeValues) {
        Optional<EnvId> envId = dsnAttributeValues.getEnvIdAttributeValue().map(EnvId::fromAttributeValue);
        Optional<Ret> ret = dsnAttributeValues.getRetAttributeValue().map(Ret::fromAttributeValue);
        Map<MailAddress, EnumSet<Notify>> notify = dsnAttributeValues.getNotifyAttributeValue()
            .map(mapAttributeValue -> mapAttributeValue.value()
                .entrySet()
                .stream()
                .map(Throwing.function(entry -> Pair.of(new MailAddress(entry.getKey()), Notify.fromAttributeValue(entry.getValue()))))
                .collect(ImmutableMap.toImmutableMap(Map.Entry::getKey, Map.Entry::getValue)))
            .orElse(ImmutableMap.of());
        Map<MailAddress, MailAddress> orcpt = dsnAttributeValues.getOrcptAttributeValue()
            .map(mapAttributeValue -> mapAttributeValue.value()
                .entrySet()
                .stream()
                .map(Throwing.function(entry -> Pair.of(new MailAddress(entry.getKey()), new MailAddress(entry.getValue().value()))))
                .collect(ImmutableMap.toImmutableMap(
                    Pair::getKey,
                    Pair::getValue)))
            .orElse(ImmutableMap.of());
        ImmutableSet<MailAddress> rcpts = ImmutableSet.<MailAddress>builder()
            .addAll(notify.keySet())
            .addAll(orcpt.keySet())
            .build();
        ImmutableMap<MailAddress, RecipientDsnParameters> recipientDsnParameters = rcpts.stream()
            .map(rcpt -> Pair.of(rcpt, new RecipientDsnParameters(
                Optional.ofNullable(notify.get(rcpt)),
                Optional.ofNullable(orcpt.get(rcpt)))))
            .collect(ImmutableMap.toImmutableMap(
                Pair::getKey,
                Pair::getValue));
        return of(envId, ret, recipientDsnParameters);
    }

    private final Optional<EnvId> envIdParameter;
    private final Optional<Ret> retParameter;
    private final ImmutableMap<MailAddress, RecipientDsnParameters> rcptParameters;

    DsnParameters(Optional<EnvId> envIdParameter, Optional<Ret> retParameter, ImmutableMap<MailAddress, RecipientDsnParameters> rcptParameters) {
        this.envIdParameter = envIdParameter;
        this.retParameter = retParameter;
        this.rcptParameters = rcptParameters;
    }

    public DsnParameters withRcptParameters(Map<MailAddress, RecipientDsnParameters> rcptParameters) {
        return new DsnParameters(envIdParameter, retParameter, ImmutableMap.copyOf(rcptParameters));
    }

    public Optional<EnvId> getEnvIdParameter() {
        return envIdParameter;
    }

    public Optional<Ret> getRetParameter() {
        return retParameter;
    }

    public ImmutableMap<MailAddress, RecipientDsnParameters> getRcptParameters() {
        return rcptParameters;
    }

    public DsnAttributeValues toAttributes() {
        Optional<AttributeValue<String>> envIdAttributeValue = envIdParameter.map(EnvId::asString).map(AttributeValue::of);
        Optional<AttributeValue<String>> retAttributeValue = retParameter.map(Ret::toString).map(AttributeValue::of);
        Optional<AttributeValue<Map<String, AttributeValue<String>>>> notifyAttributeValue = AttributeValue.of(rcptParameters.entrySet().stream()
            .filter(entry -> entry.getValue().getNotifyParameter().isPresent())
            .map(entry -> Pair.of(entry.getKey().asString(),
                Notify.toAttributeValue(entry.getValue().getNotifyParameter().get())))
            .collect(ImmutableMap.toImmutableMap(Pair::getKey, Pair::getValue)))
            .asMapAttributeValueOf(String.class);
        Optional<AttributeValue<Map<String, AttributeValue<String>>>> orcptAttributeValue = AttributeValue.of(rcptParameters.entrySet().stream()
            .filter(entry -> entry.getValue().getOrcptParameter().isPresent())
            .map(entry -> Pair.of(entry.getKey().asString(),
                AttributeValue.of(entry.getValue().getOrcptParameter().get().asString())))
            .collect(ImmutableMap.toImmutableMap(Pair::getKey, Pair::getValue)))
            .asMapAttributeValueOf(String.class);

        return new DsnAttributeValues(notifyAttributeValue, orcptAttributeValue, envIdAttributeValue, retAttributeValue);
    }

    @Override
    public final boolean equals(Object o) {
        if (o instanceof DsnParameters) {
            DsnParameters that = (DsnParameters) o;

            return Objects.equals(this.envIdParameter, that.envIdParameter)
                && Objects.equals(this.retParameter, that.retParameter)
                && Objects.equals(this.rcptParameters, that.rcptParameters);
        }
        return false;
    }

    @Override
    public final int hashCode() {
        return Objects.hash(envIdParameter, retParameter, rcptParameters);
    }

    @Override
    public String toString() {
        return MoreObjects.toStringHelper(this)
            .add("envIdParameter", envIdParameter)
            .add("retParameter", retParameter)
            .add("rcptParameters", rcptParameters)
            .toString();
    }
}
