#region Apache License
//
// 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.
//
#endregion

using System;
using System.IO;
using System.Net;
using System.Net.Mail;
using System.Text;

using log4net.Appender;
using log4net.Core;
using log4net.Ext.Mail.Appender.Internal;
using log4net.Util;
using MailKit.Security;

using MimeKit;
using MimeKit.Text;

namespace log4net.Ext.Mail.Appender;

/// <summary>
/// Send an e-mail when a specific logging event occurs, typically on errors
/// or fatal errors, using MailKit as the SMTP client.
/// </summary>
/// <remarks>
/// <para>
/// This appender exposes the same options as <see cref="log4net.Appender.SmtpAppender"/>,
/// so an existing configuration can be pointed at this type without change. The difference
/// is the transport: it sends through <see cref="MailKit.Net.Smtp.SmtpClient"/> instead of
/// the obsolete <see cref="System.Net.Mail.SmtpClient"/>.
/// </para>
/// <para>
/// The number of logging events delivered in this e-mail depend on
/// the value of <see cref="BufferingAppenderSkeleton.BufferSize"/> option. The
/// <see cref="SmtpAppender"/> keeps only the last
/// <see cref="BufferingAppenderSkeleton.BufferSize"/> logging events in its
/// cyclic buffer. This keeps memory requirements at a reasonable level while
/// still delivering useful application context.
/// </para>
/// <para>
/// Authentication is supported by setting the <see cref="Authentication"/> property to
/// either <see cref="SmtpAuthentication.Basic"/> or <see cref="SmtpAuthentication.Ntlm"/>.
/// If using <see cref="SmtpAuthentication.Basic"/> authentication then the <see cref="Username"/>
/// and <see cref="Password"/> properties must also be set.
/// </para>
/// <para>
/// To set the SMTP server port use the <see cref="Port"/> property. The default port is 25.
/// </para>
/// <para>
/// Unlike <see cref="System.Net.Mail.SmtpClient"/>, MailKit has no notion of a machine-wide
/// default SMTP server, so <see cref="SmtpHost"/> is required.
/// </para>
/// </remarks>
public class SmtpAppender : BufferingAppenderSkeleton
{
  /// <summary>
  /// The port reserved for SMTP over implicit TLS, where TLS starts before the SMTP greeting
  /// rather than being negotiated with <c>STARTTLS</c>.
  /// </summary>
  private const int ImplicitTlsPort = 465;

  private readonly Func<ISmtpTransport> _transportFactory;

  /// <summary>
  /// Default constructor. Sends through <see cref="MailKitSmtpTransport"/>.
  /// </summary>
  public SmtpAppender()
    : this(static () => new MailKitSmtpTransport())
  { }

  /// <summary>
  /// Creates an appender that obtains its transport from <paramref name="transportFactory"/>.
  /// </summary>
  /// <param name="transportFactory">
  /// Called once per e-mail to create the transport used to send it.
  /// </param>
  internal SmtpAppender(Func<ISmtpTransport> transportFactory)
    => _transportFactory = transportFactory.EnsureNotNull();

  /// <summary>
  /// Gets or sets a comma-delimited list of recipient e-mail addresses.
  /// </summary>
  public string? To
  {
    get;
    set => field = MaybeTrimSeparators(value);
  }

  /// <summary>
  /// Gets or sets a comma-delimited list of recipient e-mail addresses
  /// that will be carbon copied.
  /// </summary>
  public string? Cc
  {
    get;
    set => field = MaybeTrimSeparators(value);
  }

  /// <summary>
  /// Gets or sets a comma-delimited list of recipient e-mail addresses
  /// that will be blind carbon copied.
  /// </summary>
  /// <value>
  /// A comma-delimited list of e-mail addresses.
  /// </value>
  /// <remarks>
  /// <para>
  /// Semicolons are also accepted as separators, for backward compatibility.
  /// </para>
  /// </remarks>
  public string? Bcc
  {
    get;
    set => field = MaybeTrimSeparators(value);
  }

  /// <summary>
  /// Gets or sets the e-mail address of the sender.
  /// </summary>
  /// <value>
  /// The e-mail address of the sender.
  /// </value>
  public string? From { get; set; }

  /// <summary>
  /// Gets or sets the subject line of the e-mail message.
  /// </summary>
  /// <value>
  /// The subject line of the e-mail message.
  /// </value>
  public string? Subject { get; set; }

  /// <summary>
  /// Gets or sets the name of the SMTP relay mail server to use to send
  /// the e-mail messages.
  /// </summary>
  /// <value>
  /// The name of the e-mail relay server.
  /// </value>
  /// <remarks>
  /// <para>
  /// This option is required. MailKit, unlike <see cref="System.Net.Mail.SmtpClient"/>,
  /// has no machine-wide default SMTP server to fall back on.
  /// </para>
  /// </remarks>
  public string? SmtpHost { get; set; }

  /// <summary>
  /// The mode to use to authentication with the SMTP server
  /// </summary>
  /// <remarks>
  /// <para>
  /// Valid Authentication mode values are: <see cref="SmtpAuthentication.None"/>,
  /// <see cref="SmtpAuthentication.Basic"/>, and <see cref="SmtpAuthentication.Ntlm"/>.
  /// The default value is <see cref="SmtpAuthentication.None"/>. When using
  /// <see cref="SmtpAuthentication.Basic"/> you must specify the <see cref="Username"/>
  /// and <see cref="Password"/> to use to authenticate.
  /// </para>
  /// <para>
  /// <see cref="SmtpAuthentication.Ntlm"/> authenticates with the NTLM SASL mechanism.
  /// Note that MailKit cannot reuse the Windows logon session of the current thread or
  /// process the way <see cref="System.Net.Mail.SmtpClient"/> could, so
  /// <see cref="Username"/> and <see cref="Password"/> must be supplied for NTLM as well.
  /// </para>
  /// </remarks>
  public SmtpAuthentication Authentication { get; set; } = SmtpAuthentication.None;

  /// <summary>
  /// The username to use to authenticate with the SMTP server
  /// </summary>
  /// <remarks>
  /// <para>
  /// A <see cref="Username"/> and <see cref="Password"/> must be specified when
  /// <see cref="Authentication"/> is set to <see cref="SmtpAuthentication.Basic"/>
  /// or <see cref="SmtpAuthentication.Ntlm"/>, otherwise the username will be ignored.
  /// </para>
  /// </remarks>
  public string? Username { get; set; }

  /// <summary>
  /// The password to use to authenticate with the SMTP server
  /// </summary>
  /// <remarks>
  /// <para>
  /// A <see cref="Username"/> and <see cref="Password"/> must be specified when
  /// <see cref="Authentication"/> is set to <see cref="SmtpAuthentication.Basic"/>
  /// or <see cref="SmtpAuthentication.Ntlm"/>, otherwise the password will be ignored.
  /// </para>
  /// </remarks>
  public string? Password { get; set; }

  /// <summary>
  /// The port on which the SMTP server is listening
  /// </summary>
  /// <remarks>
  /// <para>
  /// The port on which the SMTP server is listening. The default
  /// port is <c>25</c>.
  /// </para>
  /// </remarks>
  public int Port { get; set; } = 25;

  /// <summary>
  /// Gets or sets the priority of the e-mail message
  /// </summary>
  /// <value>
  /// One of the <see cref="MailPriority"/> values.
  /// </value>
  /// <remarks>
  /// <para>
  /// Sets the priority of the e-mails generated by this
  /// appender. The default priority is <see cref="MailPriority.Normal"/>.
  /// </para>
  /// <para>
  /// If you are using this appender to report errors then
  /// you may want to set the priority to <see cref="MailPriority.High"/>.
  /// </para>
  /// <para>
  /// The value is mapped onto the MIME <c>Priority</c> header:
  /// <see cref="MailPriority.Low"/> becomes <see cref="MessagePriority.NonUrgent"/>,
  /// <see cref="MailPriority.Normal"/> becomes <see cref="MessagePriority.Normal"/> and
  /// <see cref="MailPriority.High"/> becomes <see cref="MessagePriority.Urgent"/>.
  /// </para>
  /// </remarks>
  public MailPriority Priority { get; set; } = MailPriority.Normal;

  /// <summary>
  /// Enable or disable use of SSL/TLS when sending e-mail message
  /// </summary>
  /// <remarks>
  /// <para>
  /// This is a shorthand for <see cref="TransportSecurity"/>: setting it to
  /// <see langword="true"/> selects <see cref="SmtpTransportSecurity.Required"/> and setting it to
  /// <see langword="false"/> selects <see cref="SmtpTransportSecurity.None"/>. The two properties
  /// are the same setting, so the one assigned last wins.
  /// </para>
  /// <para>
  /// When <see langword="true"/>, transport security is required: implicit TLS on port 465 and
  /// <c>STARTTLS</c> on every other port. Connecting fails if the server does not offer TLS,
  /// rather than continuing unencrypted, which matches the behaviour of
  /// <see cref="System.Net.Mail.SmtpClient.EnableSsl"/>. Use <see cref="TransportSecurity"/> when
  /// the server needs something else.
  /// </para>
  /// </remarks>
  public bool EnableSsl
  {
    get => TransportSecurity != SmtpTransportSecurity.None;
    set => TransportSecurity = value ? SmtpTransportSecurity.Required : SmtpTransportSecurity.None;
  }

  /// <summary>
  /// Gets or sets how the connection to the SMTP server is secured.
  /// </summary>
  /// <value>
  /// One of the <see cref="SmtpTransportSecurity"/> values. The default is
  /// <see cref="SmtpTransportSecurity.None"/>.
  /// </value>
  /// <remarks>
  /// <para>
  /// <see cref="EnableSsl"/> is a shorthand for this property and covers the usual cases; set this
  /// one when the server expects implicit TLS on a port other than 465, or when only opportunistic
  /// <c>STARTTLS</c> is possible.
  /// </para>
  /// </remarks>
  public SmtpTransportSecurity TransportSecurity { get; set; } = SmtpTransportSecurity.None;

  /// <summary>
  /// Gets or sets the reply-to e-mail address.
  /// </summary>
  public string? ReplyTo { get; set; }

  /// <summary>
  /// Gets or sets the subject encoding to be used.
  /// </summary>
  /// <remarks>
  /// The default encoding is <see cref="Encoding.UTF8"/>.
  /// </remarks>
  public Encoding SubjectEncoding { get; set; } = Encoding.UTF8;

  /// <summary>
  /// Gets or sets the body encoding to be used.
  /// </summary>
  /// <remarks>
  /// The default encoding is <see cref="Encoding.UTF8"/>.
  /// </remarks>
  public Encoding BodyEncoding { get; set; } = Encoding.UTF8;

  /// <summary>
  /// Sends the contents of the cyclic buffer as an e-mail message.
  /// </summary>
  /// <param name="events">The logging events to send.</param>
  protected override void SendBuffer(LoggingEvent[] events)
  {
    events.EnsureNotNull();
    // Note: this code already owns the monitor for this
    // appender. This frees us from needing to synchronize again.
    try
    {
      using StringWriter writer = new(System.Globalization.CultureInfo.InvariantCulture);

      if (Layout?.Header is string header)
      {
        writer.Write(header);
      }

      for (int i = 0; i < events.Length; i++)
      {
        // Render the event and append the text to the buffer
        RenderLoggingEvent(writer, events[i]);
      }

      if (Layout?.Footer is string footer)
      {
        writer.Write(footer);
      }

      SendEmail(writer.ToString());
    }
    catch (Exception e) when (!e.IsFatal())
    {
      ErrorHandler.Error("Error occurred while sending e-mail notification.", e);
    }
  }

  /// <summary>
  /// This appender requires a <see cref="AppenderSkeleton.Layout"/> to be set.
  /// </summary>
  protected override bool RequiresLayout => true;

  /// <summary>
  /// Translates <see cref="TransportSecurity"/> into the mail library's own representation.
  /// </summary>
  /// <returns>The transport security to connect with.</returns>
  /// <remarks>
  /// <para>
  /// <see cref="SecureSocketOptions.Auto"/> is deliberately never used: away from port 465 it is
  /// opportunistic, so an attacker who strips <c>STARTTLS</c> from the EHLO response silently
  /// downgrades the session to plaintext, taking the credentials and the log content with it.
  /// Opportunistic behavior is available, but only by asking for it with
  /// <see cref="SmtpTransportSecurity.StartTlsWhenAvailable"/>.
  /// </para>
  /// </remarks>
  private SecureSocketOptions ResolveSecureSocketOptions() => TransportSecurity switch
  {
    SmtpTransportSecurity.None => SecureSocketOptions.None,
    SmtpTransportSecurity.Required => Port == ImplicitTlsPort
      ? SecureSocketOptions.SslOnConnect
      : SecureSocketOptions.StartTls,
    SmtpTransportSecurity.ImplicitTls => SecureSocketOptions.SslOnConnect,
    SmtpTransportSecurity.StartTls => SecureSocketOptions.StartTls,
    SmtpTransportSecurity.StartTlsWhenAvailable => SecureSocketOptions.StartTlsWhenAvailable,
    _ => throw SystemInfo.CreateArgumentOutOfRangeException(nameof(TransportSecurity), TransportSecurity,
      $"The value specified for TransportSecurity is not one of the {nameof(SmtpTransportSecurity)} values.")
  };

  /// <summary>
  /// Send the email message
  /// </summary>
  /// <param name="messageBody">the body text to include in the mail</param>
  protected virtual void SendEmail(string messageBody)
  {
    using MimeMessage message = CreateMessage(messageBody);
    using ISmtpTransport transport = _transportFactory().EnsureNotNull();

    transport.Connect(SmtpHost.EnsureNotNullOrEmpty(), Port, ResolveSecureSocketOptions());
    try
    {
      switch (Authentication)
      {
        case SmtpAuthentication.Basic:
          transport.Authenticate(new NetworkCredential(Username, Password));
          break;
        case SmtpAuthentication.Ntlm:
          transport.Authenticate(new SaslMechanismNtlm(new NetworkCredential(Username, Password)));
          break;
        case SmtpAuthentication.None:
        default:
          break;
      }

      transport.Send(message);
    }
    finally
    {
      transport.Disconnect(true);
    }
  }

  /// <summary>
  /// Builds the <see cref="MimeMessage"/> for the given body text from the configured options.
  /// </summary>
  /// <param name="messageBody">the body text to include in the mail</param>
  /// <returns>the message to send</returns>
  /// <remarks>
  /// <para>
  /// Not CLS compliant, because <see cref="MimeMessage"/> comes from MimeKit, which does not
  /// declare itself CLS compliant.
  /// </para>
  /// </remarks>
  [CLSCompliant(false)]
  protected virtual MimeMessage CreateMessage(string messageBody)
  {
    MimeMessage message = new();
    message.From.AddRange(ParseAddresses(From.EnsureNotNullOrEmpty()));
    message.To.AddRange(ParseAddresses(To.EnsureNotNullOrEmpty()));
    if (!string.IsNullOrEmpty(Cc))
    {
      message.Cc.AddRange(ParseAddresses(Cc!));
    }
    if (!string.IsNullOrEmpty(Bcc))
    {
      message.Bcc.AddRange(ParseAddresses(Bcc!));
    }
    if (!string.IsNullOrEmpty(ReplyTo))
    {
      message.ReplyTo.AddRange(ParseAddresses(ReplyTo!));
    }

    if (Subject is not null)
    {
      // Set through the header collection so that the configured encoding is honoured.
      message.Headers.Replace(HeaderId.Subject, SubjectEncoding, Subject);
    }

    message.Priority = Priority switch
    {
      MailPriority.Low => MessagePriority.NonUrgent,
      MailPriority.High => MessagePriority.Urgent,
      _ => MessagePriority.Normal,
    };

    TextPart body = new(TextFormat.Plain);
    body.SetText(BodyEncoding, messageBody);
    message.Body = body;

    return message;
  }

  /// <summary>
  /// Values for the <see cref="Authentication"/> property.
  /// </summary>
  /// <remarks>
  /// <para>
  /// SMTP authentication modes.
  /// </para>
  /// </remarks>
  public enum SmtpAuthentication
  {
    /// <summary>
    /// No authentication
    /// </summary>
    None,

    /// <summary>
    /// Basic authentication.
    /// </summary>
    /// <remarks>
    /// Requires a username and password to be supplied
    /// </remarks>
    Basic,

    /// <summary>
    /// NTLM authentication.
    /// </summary>
    /// <remarks>
    /// Requires a username and password to be supplied; MailKit cannot reuse the
    /// Windows logon session of the current thread or process.
    /// </remarks>
    Ntlm
  }

  // Allow semicolon delimiters for backward compatibility.
  private static readonly char[] _addressDelimiters = [',', ';'];

  /// <summary>
  /// Trims leading and trailing commas or semicolons
  /// </summary>
  private static string? MaybeTrimSeparators(string? s) => s?.Trim(_addressDelimiters);

  /// <summary>
  /// Parses a comma- or semicolon-delimited list of addresses.
  /// </summary>
  /// <remarks>
  /// RFC 5322 only allows commas, which is what <see cref="InternetAddressList.Parse(string)"/>
  /// accepts, so semicolon-delimited lists are retried after normalization.
  /// </remarks>
  private static InternetAddressList ParseAddresses(string addresses)
    => InternetAddressList.TryParse(addresses, out InternetAddressList? list)
      ? list
      : InternetAddressList.Parse(ReplaceUnquotedSemicolons(addresses));

  /// <summary>
  /// Replaces every semicolon that is not inside a quoted string with a comma.
  /// </summary>
  private static string ReplaceUnquotedSemicolons(string addresses)
  {
    StringBuilder result = new(addresses.Length);
    bool inQuotes = false;
    bool escaped = false;
    foreach (char character in addresses)
    {
      if (escaped)
      {
        escaped = false;
        result.Append(character);
        continue;
      }

      switch (character)
      {
        case '\\' when inQuotes:
          escaped = true;
          result.Append(character);
          break;
        case '"':
          inQuotes = !inQuotes;
          result.Append(character);
          break;
        case ';' when !inQuotes:
          result.Append(',');
          break;
        default:
          result.Append(character);
          break;
      }
    }
    return result.ToString();
  }
}
