/*
 * This file contains a modified version of the APRS Parser from APRSsharp:
 * https://github.com/CBielstein/APRSsharp/tree/main/src/AprsParser
 * The source of APRS Parser was retrieved and modified for use in skyscraper5 on 20.06.2022
 *
 * The following changes were made to the APRS Parser
 * - (back-)ported from .NET 6 to .NET 5
 * - Put everything into a single file.
 * - Replaced Geocoordinate Portable with a custom implementation
 * - Prevent Exception Throwing on some APRS Data Types.
 * - Added support to raw APRS messages. (Data Type ":")
 *
 * Since APRS Parser is licensed under MIT, and skyscraper5 is as well, there should be no
 * licensing issues.
 */
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using skyscraper5.Aprs.AprsSharp;

namespace AprsSharp.Parsers.Aprs
{
    /// <summary>
    /// Specifies whether a function is referring to latitude or longitude during Maidenhead gridsquare encode/decode.
    /// </summary>
    public enum CoordinateSystem
    {
        /// <summary>
        /// Latitute.
        /// </summary>
        Latitude,

        /// <summary>
        /// Longitude.
        /// </summary>
        Longitude,
    }

    /// <summary>
    /// Used to specify format during decode of raw GPS data packets.
    /// </summary>
    public enum NmeaType
    {
        /// <summary>
        /// Not yet decoded
        /// </summary>
        NotDecoded,

        /// <summary>
        /// GGA: Global Position System Fix Data
        /// </summary>
        GGA,

        /// <summary>
        /// GLL: Geographic Position, Latitude/Longitude Data
        /// </summary>
        GLL,

        /// <summary>
        /// RMC: Recommended Minimum Specific GPS/Transit Data
        /// </summary>
        RMC,

        /// <summary>
        /// VTG: Velocity and Track Data
        /// </summary>
        VTG,

        /// <summary>
        /// WPT: Way Point Location (also WPL)
        /// </summary>
        WPT,

        /// <summary>
        /// Not supported/known type
        /// </summary>
        Unknown,
    }

    /// <summary>
    /// The APRS packet type.
    /// </summary>
    public enum PacketType
    {
        /// <summary>
        /// Current Mic-E Data (Rev 0 beta)
        /// </summary>
        CurrentMicEData,

        /// <summary>
        /// Old Mic-E Data (Rev 0 beta)
        /// </summary>
        OldMicEData,

        /// <summary>
        /// Position without timestamp (no APRS messaging), or Ultimeter 2000 WX Station
        /// </summary>
        PositionWithoutTimestampNoMessaging,

        /// <summary>
        /// Peet Bros U-II Weather Station
        /// </summary>
        PeetBrosUIIWeatherStation,

        /// <summary>
        /// Raw GPS data or Ultimeter 2000
        /// </summary>
        RawGPSData,

        /// <summary>
        /// Agrelo DFJr / MicroFinder
        /// </summary>
        AgreloDFJrMicroFinder,

        /// <summary>
        /// [Reserved - Map Feature]
        /// </summary>
        MapFeature,

        /// <summary>
        /// Old Mic-E Data (but Current data for TM-D700)
        /// </summary>
        OldMicEDataCurrentTMD700,

        /// <summary>
        /// Item
        /// </summary>
        Item,

        /// <summary>
        /// [Reserved - shelter data with time]
        /// </summary>
        ShelterDataWithTime,

        /// <summary>
        /// Invalid data or test data
        /// </summary>
        InvalidOrTestData,

        /// <summary>
        /// [Reserved - Space Weather]
        /// </summary>
        SpaceWeather,

        /// <summary>
        /// Unused
        /// </summary>
        Unused,

        /// <summary>
        /// Position with timestamp (no APRS messaging)
        /// </summary>
        PositionWithTimestampNoMessaging,

        /// <summary>
        /// Message
        /// </summary>
        Message,

        /// <summary>
        /// Object
        /// </summary>
        [field: System.Diagnostics.CodeAnalysis.SuppressMessage("Microsoft.Naming", "CA1720", Justification = "APRS spec uses the word 'object', so it is appropriate here")]
        Object,

        /// <summary>
        /// Station Capabilities
        /// </summary>
        StationCapabilities,

        /// <summary>
        /// Position without timestamp (with APRS messaging)
        /// </summary>
        PositionWithoutTimestampWithMessaging,

        /// <summary>
        /// Status
        /// </summary>
        Status,

        /// <summary>
        /// Query
        /// </summary>
        Query,

        /// <summary>
        /// [Do not use]
        /// </summary>
        DoNotUse,

        /// <summary>
        /// Positionwith timestamp (with APRS messaging)
        /// </summary>
        PositionWithTimestampWithMessaging,

        /// <summary>
        /// Telemetry data
        /// </summary>
        TelemetryData,

        /// <summary>
        /// Maidenhead grid locator beacon (obsolete)
        /// </summary>
        MaidenheadGridLocatorBeacon,

        /// <summary>
        /// Weather Report (without position)
        /// </summary>
        WeatherReport,

        /// <summary>
        /// Current Mic-E Data (not used in TM-D700)
        /// </summary>
        CurrentMicEDataNotTMD700,

        /// <summary>
        /// User-Defined APRS packet format
        /// </summary>
        UserDefinedAPRSPacketFormat,

        /// <summary>
        /// [Do not use - TNC stream switch character]
        /// </summary>
        DoNotUseTNSStreamSwitchCharacter,

        /// <summary>
        /// Third-party traffic
        /// </summary>
        ThirdPartyTraffic,

        /// <summary>
        /// Not a recognized symbol
        /// </summary>
        Unknown,
    }

    /// <summary>
    /// Represents the type of APRS timestamp.
    /// </summary>
    public enum TimestampType
    {
        /// <summary>
        /// Days/Hours/Minutes zulu
        /// </summary>
        DHMz,

        /// <summary>
        /// Days/Hours/Minutes local
        /// </summary>
        DHMl,

        /// <summary>
        /// Hours/Minutes/Seconds (always zulu)
        /// </summary>
        HMS,

        /// <summary>
        /// Hours/Minutes/Seconds (always zulu)
        /// </summary>
        MDHM,

        /// <summary>
        /// Not a decoded timestamp
        /// </summary>
        NotDecoded,
    }
}

namespace AprsSharp.Parsers.Aprs.Extensions
{
    using System;
    using System.Collections.Generic;
    using System.Linq;

    /// <summary>
    /// Extension methods for conversion of enum types.
    /// </summary>
    public static class EnumConversionExtensions
    {
        /// <summary>
        /// Maps a type-identifying character to a packet <see cref="PacketType"/>.
        /// </summary>
        private static readonly Dictionary<char, PacketType> DataTypeMap = new Dictionary<char, PacketType>()
        {
            { (char)0x1c, PacketType.CurrentMicEData },
            { (char)0x1d, PacketType.OldMicEData },
            { '!', PacketType.PositionWithoutTimestampNoMessaging },
            { '\"', PacketType.Unused },
            { '#', PacketType.PeetBrosUIIWeatherStation },
            { '$', PacketType.RawGPSData },
            { '%', PacketType.AgreloDFJrMicroFinder },
            { '&', PacketType.MapFeature },
            { '\'', PacketType.OldMicEDataCurrentTMD700 },
            { '(', PacketType.Unused },
            { ')', PacketType.Item },
            { '*', PacketType.PeetBrosUIIWeatherStation },
            { '+', PacketType.ShelterDataWithTime },
            { ',', PacketType.InvalidOrTestData },
            { '-', PacketType.Unused },
            { '.', PacketType.SpaceWeather },
            { '/', PacketType.PositionWithTimestampNoMessaging },
            { ':', PacketType.Message },
            { ';', PacketType.Object },
            { '<', PacketType.StationCapabilities },
            { '=', PacketType.PositionWithoutTimestampWithMessaging },
            { '>', PacketType.Status },
            { '?', PacketType.Query },
            { '@', PacketType.PositionWithTimestampWithMessaging },
            { 'T', PacketType.TelemetryData },
            { '[', PacketType.MaidenheadGridLocatorBeacon },
            { '\\', PacketType.Unused },
            { ']', PacketType.Unused },
            { '^', PacketType.Unused },
            { '_', PacketType.WeatherReport },
            { '`', PacketType.CurrentMicEDataNotTMD700 },
            { '{', PacketType.UserDefinedAPRSPacketFormat },
            { '}', PacketType.ThirdPartyTraffic },
            { 'A', PacketType.DoNotUse },
            { 'S', PacketType.DoNotUse },
            { 'U', PacketType.DoNotUse },
            { 'Z', PacketType.DoNotUse },
            { '0', PacketType.DoNotUse },
            { '9', PacketType.DoNotUse },
        };

        /// <summary>
        /// Converts a char to its corresponding <see cref="PacketType"/>.
        /// </summary>
        /// <param name="typeIdentifier">A char representation of <see cref="PacketType"/>.</param>
        /// <returns><see cref="PacketType"/> of the info field, <see cref="PacketType.Unknown"/> if not a valid mapping.</returns>
        public static PacketType ToPacketType(this char typeIdentifier) => DataTypeMap.GetValueOrDefault(char.ToUpperInvariant(typeIdentifier), PacketType.Unknown);

        /// <summary>
        /// Converts to the char representation of a given <see cref="PacketType"/>.
        /// </summary>
        /// <param name="type"><see cref="PacketType"/> to represent.</param>
        /// <returns>A char representing type.</returns>
        public static char ToChar(this PacketType type) => DataTypeMap.First(pair => pair.Value == type).Key;

        /// <summary>
        /// Determines the <see cref="NmeaType"/> of a raw GPS packet determined by a string.
        /// If the string is length 3, the three letters are taken as is.
        /// If the string is length 6 or longer, the indentifier is expected in the place dictated by the NMEA formats.
        /// </summary>
        /// <param name="nmeaInput">String of length 3 identifying a raw GPS type or an entire NMEA string.</param>
        /// <returns>The raw GPS <see cref="NmeaType"/> represented by the argument.</returns>
        public static NmeaType ToNmeaType(this string nmeaInput)
        {
            if (nmeaInput == null)
            {
                throw new ArgumentNullException(nameof(nmeaInput));
            }
            else if (nmeaInput.Length != 3 && nmeaInput.Length < 6)
            {
                throw new ArgumentException("rawGpsIdentifier should be exactly 3 characters or at least 6. Given: " + nmeaInput.Length, nameof(nmeaInput));
            }
            else if (nmeaInput.Length >= 6)
            {
                throw new NotImplementedException();
            }

            return nmeaInput.ToUpperInvariant() switch
            {
                "GGA" => NmeaType.GGA,
                "GLL" => NmeaType.GLL,
                "RMC" => NmeaType.RMC,
                "VTG" => NmeaType.VTG,
                "WPT" => NmeaType.WPT,
                "WPL" => NmeaType.WPT,
                _ => NmeaType.Unknown,
            };
        }
    }
}

namespace AprsSharp.Parsers.Aprs.Extensions
{
    using System;
    using System.Text.RegularExpressions;

    /// <summary>
    /// Extension methods for <see cref="Match"/>.
    /// </summary>
    public static class MatchExtensions
    {
        /// <summary>
        /// Asserts success of a Regex.Match call.
        /// If the Match object is not successful, throws an ArgumentException.
        /// </summary>
        /// <param name="match">Match object to check for success.</param>
        /// <param name="type">Type that was being checked against the regex.</param>
        /// <param name="paramName">The name of the param that did not match the regex.</param>
        public static void AssertSuccess(this Match match, string type, string paramName)
        {
            if (match == null)
            {
                throw new ArgumentNullException(nameof(match));
            }
            else if (!match.Success)
            {
                throw new ArgumentException($"{type} did not match regex", paramName);
            }
        }

        /// <summary>
        /// Asserts success of a Regex.Match call.
        /// If the Match object is not successful, throws an ArgumentException.
        /// </summary>
        /// <param name="match">Match object to check for success.</param>
        /// <param name="type"><see cref="PacketType"/> that was being checked against the regex.</param>
        /// <param name="paramName">The name of the param that did not match the regex.</param>
        public static void AssertSuccess(this Match match, PacketType type, string paramName)
        {
            match.AssertSuccess(type.ToString(), paramName);
        }
    }
}

namespace AprsSharp.Parsers.Aprs.Extensions
{
    using System;
    using System.Globalization;

    /// <summary>
    /// Extension methods for handling weather packets.
    /// </summary>
    public static class WeatherExtensions
    {
        /// <summary>
        /// Determines if a <see cref="Position"/>'s symbol is a weather station.
        /// </summary>
        /// <param name="position">A <see cref="Position"/> to check.</param>
        /// <returns>True if the <see cref="Position"/>'s symbol is a weather station, else false.</returns>
        public static bool IsWeatherSymbol(this Position position)
        {
            if (position == null)
            {
                throw new ArgumentNullException(nameof(position));
            }

            return (position.SymbolTableIdentifier == '/' || position.SymbolTableIdentifier == '\\') &&
                   position.SymbolCode == '_';
        }

        /// <summary>
        /// Returns a string encoding of a single measurement for a complete weather packet.
        /// </summary>
        /// <param name="measurement">The measurement to convert to string.</param>
        /// <param name="encodingLength">The number of characters to use in this representation.</param>
        /// <returns>The integer as a string or all dots if null.</returns>
        public static string ToWeatherEncoding(this int? measurement, int encodingLength = 3)
        {
            if (measurement == null)
            {
                return new string('.', encodingLength);
            }

            string encoded = Math.Abs(measurement.Value).ToString(CultureInfo.InvariantCulture)
                .PadLeft(encodingLength, '0');

            // If negative, ensure the negative sign is at the front of the padding zeros instead of in the middle
            // that's why we take the absolute value above, so that we can put the negative sign all the way at the front.
            if (measurement < 0)
            {
                encoded = $"-{encoded.Substring(1)}";
            }

            return encoded;
        }
    }
}

namespace AprsSharp.Parsers.Aprs
    {
        using System;
        using AprsSharp.Parsers.Aprs.Extensions;

        /// <summary>
        /// A representation of an info field on an APRS packet.
        /// </summary>
        public abstract class InfoField
        {
            /// <summary>
            /// Initializes a new instance of the <see cref="InfoField"/> class.
            /// </summary>
            public InfoField()
            {
                Type = PacketType.Unknown;
            }

            /// <summary>
            /// Initializes a new instance of the <see cref="InfoField"/> class from an encoded string.
            /// </summary>
            /// <param name="encodedInfoField">An encoded InfoField from which to pull the Type.</param>
            public InfoField(string encodedInfoField)
            {
                if (encodedInfoField == null)
                {
                    throw new ArgumentNullException(nameof(encodedInfoField));
                }

                Type = GetPacketType(encodedInfoField);
            }

            /// <summary>
            /// Initializes a new instance of the <see cref="InfoField"/> class from a <see cref="PacketType"/>.
            /// </summary>
            /// <param name="type">The <see cref="PacketType"/> of this <see cref="InfoField"/>.</param>
            public InfoField(PacketType type)
            {
                Type = type;
            }

            /// <summary>
            /// Initializes a new instance of the <see cref="InfoField"/> class from another <see cref="InfoField"/>.
            /// This is the copy constructor.
            /// </summary>
            /// <param name="infoField">An <see cref="InfoField"/> to copy.</param>
            public InfoField(InfoField infoField)
            {
                if (infoField == null)
                {
                    throw new ArgumentNullException(nameof(infoField));
                }

                Type = infoField.Type;
            }

            /// <summary>
            /// Gets or sets the <see cref="PacketType"/> of this packet.
            /// </summary>
            public PacketType Type { get; protected set; }

            /// <summary>
            /// Instantiates a type of <see cref="InfoField"/> from the given string.
            /// </summary>
            /// <param name="encodedInfoField">String representation of the APRS info field.</param>
            /// <returns>A class extending <see cref="InfoField"/>.</returns>
            public static InfoField FromString(string encodedInfoField)
            {
                PacketType type = GetPacketType(encodedInfoField);

                switch (type)
                {
                    case PacketType.PositionWithoutTimestampNoMessaging:
                    case PacketType.PositionWithoutTimestampWithMessaging:
                    case PacketType.PositionWithTimestampNoMessaging:
                    case PacketType.PositionWithTimestampWithMessaging:
                        PositionInfo positionInfo = new PositionInfo(encodedInfoField);
                        return positionInfo.Position.IsWeatherSymbol() ? new WeatherInfo(positionInfo) : positionInfo;

                    case PacketType.Status:
                        return new StatusInfo(encodedInfoField);

                    case PacketType.MaidenheadGridLocatorBeacon:
                        return new MaidenheadBeaconInfo(encodedInfoField);

                    case PacketType.CurrentMicEDataNotTMD700:
                        return new UnimplementedInfoField(encodedInfoField);

                    case PacketType.Unknown:
                        return new UnimplementedInfoField(encodedInfoField);

                    case PacketType.Object:
                        return new UnimplementedInfoField(encodedInfoField);

                    case PacketType.Message:
                        return new MessageField(encodedInfoField);

                    case PacketType.TelemetryData:
                        return new UnimplementedInfoField(encodedInfoField);

                    case PacketType.StationCapabilities:
                        return new StationCapabilities(encodedInfoField);

                    case PacketType.WeatherReport:
                        return new WeatherReportInfoField(encodedInfoField);

                    case PacketType.OldMicEDataCurrentTMD700:
                        return new UnimplementedInfoField(encodedInfoField);

                    case PacketType.DoNotUse:
                        return new UnimplementedInfoField(encodedInfoField);

                    case PacketType.Item:
                        return new UnimplementedInfoField(encodedInfoField);

                    case PacketType.AgreloDFJrMicroFinder:
                        return new UnimplementedInfoField(encodedInfoField);

                    case PacketType.RawGPSData:
                        return new RawGpsInfoField(encodedInfoField);

                    case PacketType.SpaceWeather:
                        return new UnimplementedInfoField(encodedInfoField);

                    case PacketType.PeetBrosUIIWeatherStation:
                        return new UnimplementedInfoField(encodedInfoField);

                    case PacketType.UserDefinedAPRSPacketFormat:
                        return new UnimplementedInfoField(encodedInfoField);

                    case PacketType.Unused:
                        return new UnimplementedInfoField(encodedInfoField);

                    case PacketType.Query:
                        return new UnimplementedInfoField(encodedInfoField);

                    case PacketType.InvalidOrTestData:
                        return new UnimplementedInfoField(encodedInfoField);

					case PacketType.ThirdPartyTraffic:
						return new UnimplementedInfoField(encodedInfoField);
                default:
                    throw new NotImplementedException($"FromString not implemented for info field type {type}");
                }
            }

            /// <summary>
            /// Encodes an APRS info field to a string.
            /// </summary>
            /// <returns>String representation of the packet.</returns>
            public abstract string Encode();

            /// <summary>
            /// Gets the <see cref="PacketType"/> of a string representation of an APRS info field.
            /// </summary>
            /// <param name="encodedInfoField">A string-encoded APRS info field.</param>
            /// <returns><see cref="PacketType"/> of the info field.</returns>
            private static PacketType GetPacketType(string encodedInfoField)
            {
                if (encodedInfoField == null)
                {
                    throw new ArgumentNullException(nameof(encodedInfoField));
                }

                // TODO Issue #67: This isn't always true.
                // '!' can come up to the 40th position.
                char dataTypeIdentifier = char.ToUpperInvariant(encodedInfoField[0]);

                return dataTypeIdentifier.ToPacketType();
            }
        }
    }

namespace AprsSharp.Parsers.Aprs
{
    using System;
    using System.Text;
    using System.Text.RegularExpressions;
    using AprsSharp.Parsers.Aprs.Extensions;

    /// <summary>
    /// Represents an info field for packet type <see cref="PacketType.MaidenheadGridLocatorBeacon"/>.
    /// </summary>
    public class MaidenheadBeaconInfo : InfoField
    {
        /// <summary>
        /// Initializes a new instance of the <see cref="MaidenheadBeaconInfo"/> class.
        /// NOTE: This packet type is considere obsolete and is included for decoding legacy devices. Use a status report instead.
        /// </summary>
        /// <param name="encodedInfoField">A string encoding of a <see cref="StatusInfo"/>.</param>
        public MaidenheadBeaconInfo(string encodedInfoField)
            : base(encodedInfoField)
        {
            if (string.IsNullOrWhiteSpace(encodedInfoField))
            {
                throw new ArgumentNullException(nameof(encodedInfoField));
            }

            if (Type != PacketType.MaidenheadGridLocatorBeacon)
            {
                throw new ArgumentException($"Packet encoding not of type {nameof(PacketType.MaidenheadGridLocatorBeacon)}. Type was {Type}", nameof(encodedInfoField));
            }

            Match match = Regex.Match(encodedInfoField, RegexStrings.MaidenheadGridLocatorBeacon);
            match.AssertSuccess(PacketType.Status, nameof(encodedInfoField));

            Position = new Position();
            Position.DecodeMaidenhead(match.Groups[1].Value);

            if (match.Groups[2].Success)
            {
                Comment = match.Groups[2].Value;
            }
        }

        /// <summary>
        /// Initializes a new instance of the <see cref="MaidenheadBeaconInfo"/> class.
        /// </summary>
        /// <param name="position">A position, which will be encoded as a maidenhead gridsquare locator.</param>
        /// <param name="comment">An optional comment.</param>
        public MaidenheadBeaconInfo(Position position, string? comment)
            : base(PacketType.MaidenheadGridLocatorBeacon)
        {
            Comment = comment;
            Position = position ?? throw new ArgumentNullException(nameof(position));
        }

        /// <summary>
        /// Gets the packet comment.
        /// </summary>
        public string? Comment { get; }

        /// <summary>
        /// Gets the position from which the message was sent.
        /// </summary>
        public Position Position { get; }

        /// <inheritdoc/>
        public override string Encode()
        {
            StringBuilder encoded = new StringBuilder();

            encoded.Append($"[{Position.EncodeGridsquare(6, false)}]");

            if (!string.IsNullOrEmpty(Comment))
            {
                encoded.Append(Comment);
            }

            return encoded.ToString();
        }
    }
}

namespace AprsSharp.Parsers.Aprs
{
    using System;
    using System.Text;
    using System.Text.RegularExpressions;
    using AprsSharp.Parsers.Aprs.Extensions;

    /// <summary>
    /// Represents an info field for the following types of packet:
    ///     * <see cref="PacketType.PositionWithoutTimestampNoMessaging"/>
    ///     * <see cref="PacketType.PositionWithTimestampNoMessaging"/>
    ///     * <see cref="PacketType.PositionWithoutTimestampWithMessaging"/>
    ///     * <see cref="PacketType.PositionWithTimestampWithMessaging"/>.
    /// </summary>
    public class PositionInfo : InfoField
    {
        /// <summary>
        /// Initializes a new instance of the <see cref="PositionInfo"/> class.
        /// </summary>
        /// <param name="encodedInfoField">A string encoding of a <see cref="PositionInfo"/>.</param>
        public PositionInfo(string encodedInfoField)
            : base(encodedInfoField)
        {
            if (string.IsNullOrWhiteSpace(encodedInfoField))
            {
                throw new ArgumentNullException(nameof(encodedInfoField));
            }

            if (Type == PacketType.PositionWithoutTimestampNoMessaging || Type == PacketType.PositionWithoutTimestampWithMessaging)
            {
                HasMessaging = Type == PacketType.PositionWithoutTimestampWithMessaging;

                Match match = Regex.Match(encodedInfoField, RegexStrings.PositionWithoutTimestamp);
                match.AssertSuccess(PacketType.PositionWithoutTimestampNoMessaging, nameof(encodedInfoField));

                Position = new Position(match.Groups[1].Value);

                if (match.Groups[6].Success)
                {
                    Comment = match.Groups[6].Value;
                }
            }
            else if (Type == PacketType.PositionWithTimestampNoMessaging || Type == PacketType.PositionWithTimestampWithMessaging)
            {
                HasMessaging = Type == PacketType.PositionWithTimestampWithMessaging;

                Match match = Regex.Match(encodedInfoField, RegexStrings.PositionWithTimestamp);
                match.AssertSuccess(
                    HasMessaging ?
                        PacketType.PositionWithTimestampWithMessaging :
                        PacketType.PositionWithTimestampNoMessaging,
                    nameof(encodedInfoField));

                Timestamp = new Timestamp(match.Groups[2].Value);

                Position = new Position(match.Groups[3].Value);

                if (match.Groups[8].Success)
                {
                    Comment = match.Groups[8].Value;
                }
            }
            else
            {
                throw new ArgumentException($"Packet encoding not one of the position types. Type was {Type}", nameof(encodedInfoField));
            }
        }

        /// <summary>
        /// Initializes a new instance of the <see cref="PositionInfo"/> class.
        /// </summary>
        /// <param name="position"><see cref="Position"/> for this packet.</param>
        /// <param name="hasMessaging">True if the sender supports messaging.</param>
        /// <param name="timestamp">Optional <see cref="Timestamp"/> for this packet.</param>
        /// <param name="comment">Optional comment for this packet.</param>
        public PositionInfo(Position position, bool hasMessaging, Timestamp? timestamp, string? comment)
        {
            Position = position;
            HasMessaging = hasMessaging;
            Timestamp = timestamp;
            Comment = comment;

            if (Timestamp == null)
            {
                Type = HasMessaging ? PacketType.PositionWithoutTimestampWithMessaging
                    : PacketType.PositionWithoutTimestampNoMessaging;
            }
            else
            {
                Type = HasMessaging ? PacketType.PositionWithTimestampWithMessaging
                    : PacketType.PositionWithTimestampNoMessaging;
            }
        }

        /// <summary>
        /// Initializes a new instance of the <see cref="PositionInfo"/> class.
        /// This is the copy constructor.
        /// </summary>
        /// <param name="positionInfo">A <see cref="PositionInfo"/> to copy.</param>
        public PositionInfo(PositionInfo positionInfo)
            : base(positionInfo)
        {
            if (positionInfo == null)
            {
                throw new ArgumentNullException(nameof(positionInfo));
            }

            HasMessaging = positionInfo.HasMessaging;
            Comment = positionInfo.Comment;
            Timestamp = positionInfo.Timestamp;
            Position = positionInfo.Position;
        }

        /// <summary>
        /// Gets a value indicating whether the sender of the packet supports messaging.
        /// </summary>
        public bool HasMessaging { get; }

        /// <summary>
        /// Gets or sets the packet comment.
        /// </summary>
        public string? Comment { get; set; }

        /// <summary>
        /// Gets the time at which the message was sent.
        /// </summary>
        public Timestamp? Timestamp { get; }

        /// <summary>
        /// Gets the position from which the message was sent.
        /// </summary>
        public Position Position { get; }

        /// <inheritdoc/>
        public override string Encode() => Encode(TimestampType.DHMz);

        /// <summary>
        /// Encodes an APRS info field to a string.
        /// </summary>
        /// <param name="timeType">The <see cref="TimestampType"/> to use for timestamp encoding.</param>
        /// <returns>String representation of the info field.</returns>
        public string Encode(TimestampType timeType = TimestampType.DHMz)
        {
            if (Position == null)
            {
                throw new ArgumentException($"Position cannot be null when encoding with type ${Type}");
            }

            StringBuilder encoded = new StringBuilder();

            encoded.Append(Type.ToChar());

            if (Type == PacketType.PositionWithTimestampWithMessaging ||
                Type == PacketType.PositionWithTimestampNoMessaging)
            {
                if (Timestamp == null)
                {
                    throw new ArgumentException($"Timestamp cannot be null when encoding with type ${Type}");
                }

                encoded.Append(Timestamp.Encode(timeType));
            }

            encoded.Append(Position.Encode());
            encoded.Append(Comment);

            return encoded.ToString();
        }
    }
}

namespace AprsSharp.Parsers.Aprs
{
    using System;
    using System.Text;
    using System.Text.RegularExpressions;
    using AprsSharp.Parsers.Aprs.Extensions;

    /// <summary>
    /// Represents an info field for packet type <see cref="PacketType.Status"/>.
    /// </summary>
    public class StatusInfo : InfoField
    {
        /// <summary>
        /// The list of characters which may not be used in a comment on this type.
        /// </summary>
        private static readonly char[] CommentDisallowedChars = { '|', '~' };

        /// <summary>
        /// Initializes a new instance of the <see cref="StatusInfo"/> class.
        /// </summary>
        /// <param name="encodedInfoField">A string encoding of a <see cref="StatusInfo"/>.</param>
        public StatusInfo(string encodedInfoField)
            : base(encodedInfoField)
        {
            if (string.IsNullOrWhiteSpace(encodedInfoField))
            {
                throw new ArgumentNullException(nameof(encodedInfoField));
            }

            if (Type != PacketType.Status)
            {
                throw new ArgumentException($"Packet encoding not of type {nameof(PacketType.Status)}. Type was {Type}", nameof(encodedInfoField));
            }

            Match match = Regex.Match(encodedInfoField, RegexStrings.StatusWithMaidenheadAndComment);
            if (match.Success)
            {
                match.AssertSuccess(PacketType.Status, nameof(encodedInfoField));

                Position = new Position();
                Position.DecodeMaidenhead(match.Groups[1].Value);

                if (match.Groups[4].Success)
                {
                    Comment = match.Groups[4].Value;
                }
            }
            else
            {
                // TODO Issue #88
                throw new ArgumentException("Status report without maidenhead not yet implemented.Tracked by issue #88.");
            }
        }

        /// <summary>
        /// Initializes a new instance of the <see cref="StatusInfo"/> class.
        /// </summary>
        /// <param name="position">A position, which will be encoded as a maidenhead gridsquare locator.</param>
        /// <param name="comment">An optional comment.</param>
        public StatusInfo(Position position, string? comment)
            : this(null, position, comment)
        {
        }

        /// <summary>
        /// Initializes a new instance of the <see cref="StatusInfo"/> class.
        /// </summary>
        /// <param name="timestamp">An optional timestamp.</param>
        /// <param name="comment">An optional comment.</param>
        public StatusInfo(Timestamp timestamp, string? comment)
            : this(timestamp, null, comment)
        {
        }

        /// <summary>
        /// Initializes a new instance of the <see cref="StatusInfo"/> class.
        /// Consolidates logic from the other constructors.
        /// </summary>
        /// <param name="timestamp">An optional timestamp.</param>
        /// <param name="position">A position, which will be encoded as a maidenhead gridsquare locator.</param>
        /// <param name="comment">An optional comment.</param>
        private StatusInfo(Timestamp? timestamp, Position? position, string? comment)
            : base(PacketType.Status)
        {
            if (position != null && timestamp != null)
            {
                throw new ArgumentException($"{nameof(timestamp)} may not be specified if a position is given.");
            }

            if (comment != null)
            {
                // Validate lengths
                if (position != null && comment.Length > 53)
                {
                    // Delimiting space + 53 characters = 54.
                    // We will add the space during encode, so only 53 here.
                    throw new ArgumentException(
                        $"With a position specified, comment may be at most 53 characters. Given comment had length {comment.Length}",
                        nameof(comment));
                }
                else if (timestamp != null && comment.Length > 55)
                {
                    throw new ArgumentException(
                        $"With a timestamp, comment may be at most 55 characters. Given comment had length {comment.Length}",
                        nameof(comment));
                }
                else if (comment.Length > 62)
                {
                    throw new ArgumentException(
                        $"Without timestamp or or position, comment may be at most 62 characters. Given comment had length {comment.Length}",
                        nameof(comment));
                }

                // TODO Issue #90: Share this logic across all packet types with a comment.
                // Validate no disallowed characters were used
                if (comment.IndexOfAny(CommentDisallowedChars) != -1)
                {
                    throw new ArgumentException($"Comment may not include `|` or `~` but was given: {comment}", nameof(comment));
                }
            }

            Timestamp = timestamp;
            Position = position;
            Comment = comment;
        }

        /// <summary>
        /// Gets the packet comment.
        /// </summary>
        public string? Comment { get; }

        /// <summary>
        /// Gets the time at which the message was sent.
        /// </summary>
        public Timestamp? Timestamp { get; }

        /// <summary>
        /// Gets the position from which the message was sent.
        /// </summary>
        public Position? Position { get; }

        /// <inheritdoc/>
        public override string Encode()
        {
            StringBuilder encoded = new StringBuilder();

            if (Position != null)
            {
                if (Timestamp != null)
                {
                    throw new ArgumentException($"{nameof(Timestamp)} may not be specified if a position is given.");
                }

                encoded.Append(Type.ToChar());
                encoded.Append(Position.EncodeGridsquare(6, true));

                if (!string.IsNullOrEmpty(Comment))
                {
                    encoded.Append($" {Comment}");
                }
            }
            else
            {
                // TODO Issue #88
                throw new NotImplementedException("Status report without maidenhead not yet implemented.Tracked by issue #88.");
            }

            return encoded.ToString();
        }
    }
}

namespace AprsSharp.Parsers.Aprs
{
    using System;
    using System.Globalization;
    using System.Text;
    using System.Text.RegularExpressions;
    using AprsSharp.Parsers.Aprs.Extensions;

    /// <summary>
    /// Represents an info field for position packets carrying weather information.
    /// </summary>
    public class WeatherInfo : PositionInfo
    {
        /// <summary>
        /// Initializes a new instance of the <see cref="WeatherInfo"/> class from an encoded <see cref="InfoField"/>.
        /// </summary>
        /// <param name="encodedInfoField">A string encoding of a <see cref="PositionInfo"/> with weather information.</param>
        public WeatherInfo(string encodedInfoField)
            : this(new PositionInfo(encodedInfoField))
        {
        }

        /// <summary>
        /// Initializes a new instance of the <see cref="WeatherInfo"/> class from a <see cref="PositionInfo"/>.
        /// </summary>
        /// <param name="positionInfo">A <see cref="PositionInfo"/> from which to decode a <see cref="WeatherInfo"/>.</param>
        public WeatherInfo(PositionInfo positionInfo)
            : base(positionInfo)
        {
            if (positionInfo == null)
            {
                throw new ArgumentNullException(nameof(positionInfo));
            }

            if (!positionInfo.Position.IsWeatherSymbol())
            {
                throw new ArgumentException(
                    $@"Encoded packet must have weather symbol (`/_` or `\_`). Given: `{positionInfo.Position.SymbolTableIdentifier}{positionInfo.Position.SymbolCode}",
                    nameof(positionInfo));
            }

            WindDirection = GetWeatherMeasurement('^');
            WindSpeed = GetWeatherMeasurement('/');
            WindGust = GetWeatherMeasurement('g');
            Temperature = GetWeatherMeasurement('t');
            Rainfall1Hour = GetWeatherMeasurement('r');
            Rainfall24Hour = GetWeatherMeasurement('p');
            RainfallSinceMidnight = GetWeatherMeasurement('P');
            Humidity = GetWeatherMeasurement('h', 2);
            BarometricPressure = GetWeatherMeasurement('b', 5);
            Luminosity = GetWeatherMeasurement('L') ?? GetWeatherMeasurement('l') + 1000;
            RainRaw = GetWeatherMeasurement('#');
            Snow = GetWeatherMeasurement('s');
        }

        /// <summary>
        /// Initializes a new instance of the <see cref="WeatherInfo"/> class.
        /// </summary>
        /// <param name="position"><see cref="Position"/> for this packet.</param>
        /// <param name="hasMessaging">True if the sender supports messaging.</param>
        /// <param name="timestamp">Optional <see cref="Timestamp"/> for this packet.</param>
        /// <param name="comment">Optional comment for this packet, will be appended after encoded weather information.</param>
        /// <param name="windDirection">Wind direction in degrees.</param>
        /// <param name="windSpeed">Wind speed 1-minute sustained in miles per hour.</param>
        /// <param name="windGust">5-minute max wind gust in miles per hour.</param>
        /// <param name="temperature">Temperature in degrees Fahrenheit.</param>
        /// <param name="rainfall1Hour">1-hour rainfall in 100ths of an inch.</param>
        /// <param name="rainfall24Hour">24-hour rainfall in 100ths of an inch.</param>
        /// <param name="rainfallSinceMidnight">Rainfall since midnight in 100ths of an inch.</param>
        /// <param name="humidity">Humidity in percentage.</param>
        /// <param name="barometricPressure">Barometric pressure in 10ths of millibars/10ths of hPascal.</param>
        /// <param name="luminosity">Luminosity in watts per square meter.</param>
        /// <param name="rainRaw">Raw rain.</param>
        /// <param name="snow">Snowfall in inches in the last 24 hours.</param>
        public WeatherInfo(
            Position position,
            bool hasMessaging,
            Timestamp? timestamp,
            string? comment,
            int? windDirection,
            int? windSpeed,
            int? windGust,
            int? temperature,
            int? rainfall1Hour,
            int? rainfall24Hour,
            int? rainfallSinceMidnight,
            int? humidity,
            int? barometricPressure,
            int? luminosity,
            int? rainRaw,
            int? snow)
            : base(position, hasMessaging, timestamp, comment)
        {
            WindDirection = windDirection;
            WindSpeed = windSpeed;
            WindGust = windGust;
            Temperature = temperature;
            Rainfall1Hour = rainfall1Hour;
            Rainfall24Hour = rainfall24Hour;
            RainfallSinceMidnight = rainfallSinceMidnight;
            Humidity = humidity;
            BarometricPressure = barometricPressure;
            Luminosity = luminosity;
            RainRaw = rainRaw;
            Snow = snow;

            Comment = $"{EncodeWeatherInfo()}{comment}";
        }

        /// <summary>
        /// Gets wind direction as degrees.
        /// </summary>
        public int? WindDirection { get; }

        /// <summary>
        /// Gets wind speed 1-minute sustained in miles per hour.
        /// </summary>
        public int? WindSpeed { get; }

        /// <summary>
        /// Gets 5-minute max wind gust in miles per hour.
        /// </summary>
        public int? WindGust { get; }

        /// <summary>
        /// Gets temperature in degrees Fahrenheit.
        /// </summary>
        public int? Temperature { get; }

        /// <summary>
        /// Gets 1-hour rainfall in 100ths of an inch.
        /// </summary>
        public int? Rainfall1Hour { get; }

        /// <summary>
        /// Gets 24-hour rainfall in 100ths of an inch.
        /// </summary>
        public int? Rainfall24Hour { get; }

        /// <summary>
        /// Gets rainfall since midnight in 100ths of an inch.
        /// </summary>
        public int? RainfallSinceMidnight { get; }

        /// <summary>
        /// Gets humidity in percentage.
        /// </summary>
        public int? Humidity { get; }

        /// <summary>
        /// Gets Barometric pressure in 10ths of millibars/10ths of hPascal.
        /// </summary>
        public int? BarometricPressure { get; }

        /// <summary>
        /// Gets luminosity in watts per square meter.
        /// </summary>
        public int? Luminosity { get; }

        /// <summary>
        /// Gets raw rain.
        /// </summary>
        public int? RainRaw { get; }

        /// <summary>
        /// Gets snowfall in inches in the last 24 hours.
        /// </summary>
        public int? Snow { get; }

        /// <summary>
        /// Retrieves an APRS weather measurement from the comment string.
        /// </summary>
        /// <param name="measurementKey">The weather element to fetch, as defined by the key the ARPS specification.</param>
        /// <param name="length">The expected number of digits in the measurement.</param>
        /// <returns>An int value, if found. Else, null.</returns>
        private int? GetWeatherMeasurement(char measurementKey, int length = 3)
        {
            // Regex below looks for the measurement key followed by either `length` numbers
            // or a negative number of `length - 1` digits (to allow for the negative sign)
            var match = Regex.Match(Comment, $"{measurementKey}(([0-9]{{{length}}})|(-[0-9]{{{length - 1}}}))");
            return match.Success ? int.Parse(match.Groups[1].Value, CultureInfo.InvariantCulture) : null;
        }

        /// <summary>
        /// Encodes weather information to be placed in the comment field.
        /// </summary>
        /// <returns>An APRS encoding of weather information on this packet.</returns>
        private string EncodeWeatherInfo()
        {
            StringBuilder sb = new StringBuilder();

            sb.Append(WindDirection.ToWeatherEncoding());
            sb.Append($"/{WindSpeed.ToWeatherEncoding()}");
            sb.Append($"g{WindGust.ToWeatherEncoding()}");
            sb.Append($"t{Temperature.ToWeatherEncoding()}");
            sb.Append($"r{Rainfall1Hour.ToWeatherEncoding()}");
            sb.Append($"p{Rainfall24Hour.ToWeatherEncoding()}");
            sb.Append($"P{RainfallSinceMidnight.ToWeatherEncoding()}");
            sb.Append($"h{Humidity.ToWeatherEncoding(2)}");
            sb.Append($"b{BarometricPressure.ToWeatherEncoding(5)}");

            // Only add less common measurements if provided
            if (Luminosity != null)
            {
                char lum = Luminosity < 1000 ? 'L' : 'l';
                sb.Append($"{lum}{(Luminosity % 1000).ToWeatherEncoding()}");
            }

            if (RainRaw != null)
            {
                sb.Append($"#{RainRaw.ToWeatherEncoding()}");
            }

            if (Snow != null)
            {
                sb.Append($"s{Snow.ToWeatherEncoding()}");
            }

            return sb.ToString();
        }
    }
}

namespace AprsSharp.Parsers.Aprs
{
    using System;
    using System.Collections.Generic;
    using System.Text.RegularExpressions;
    using AprsSharp.Parsers.Aprs.Extensions;

    /// <summary>
    /// Represents a full APRS packet.
    /// </summary>
    public class Packet
    {
        /// <summary>
        /// Initializes a new instance of the <see cref="Packet"/> class.
        /// </summary>
        /// <param name="encodedPacket">The string encoding of the APRS packet.</param>
        public Packet(string encodedPacket)
        {
            if (string.IsNullOrWhiteSpace(encodedPacket))
            {
                throw new ArgumentNullException(nameof(encodedPacket));
            }

            Match match = Regex.Match(encodedPacket, RegexStrings.Tnc2Packet);
            match.AssertSuccess("Full TNC2 Packet", nameof(encodedPacket));

            Sender = match.Groups[1].Value;
            Path = match.Groups[2].Value.Split(',');
            ReceivedTime = DateTime.UtcNow;
            InfoField = InfoField.FromString(match.Groups[3].Value);
        }

        /// <summary>
        /// Initializes a new instance of the <see cref="Packet"/> class.
        /// </summary>
        /// <param name="sender">The callsign of the sender.</param>
        /// <param name="path">The digipath for the packet.</param>
        /// <param name="infoField">An <see cref="InfoField"/> or extending class to send.</param>
        public Packet(string sender, IList<string> path, InfoField infoField)
        {
            Sender = sender;
            Path = path;
            InfoField = infoField;
            ReceivedTime = null;
        }

        /// <summary>
        /// Gets the sender's callsign.
        /// </summary>
        public string Sender { get; }

        /// <summary>
        /// Gets the APRS digipath of the packet.
        /// </summary>
        public IList<string> Path { get; }

        /// <summary>
        /// Gets the time this packet was decoded.
        /// Null if this packet was created instead of decoded.
        /// </summary>
        public DateTime? ReceivedTime { get; }

        /// <summary>
        /// Gets the APRS information field for this packet.
        /// </summary>
        public InfoField InfoField { get; }

        /// <summary>
        /// Encodes an APRS packet to a string.
        /// </summary>
        /// <returns>String representation of the packet.</returns>
        public virtual string Encode()
        {
            return $"{Sender}>{string.Join(',', Path)}:{InfoField.Encode()}";
        }
    }
}

namespace AprsSharp.Parsers.Aprs
{
    using System;
    using System.Globalization;
    using System.Text;
    using System.Text.RegularExpressions;
    using AprsSharp.Parsers.Aprs.Extensions;
    using GeoCoordinatePortable;

    /// <summary>
    /// Represents lat/long position in an APRS packet.
    /// </summary>
    public class Position
    {
        /// <summary>
        /// Initializes a new instance of the <see cref="Position"/> class.
        /// Decodes from LAT/LONG coordinates.
        /// </summary>
        /// <param name="coords">String representing LAT/LONG coordinates.</param>
        public Position(string coords)
        {
            Decode(coords);
        }

        /// <summary>
        /// Initializes a new instance of the <see cref="Position"/> class.
        /// </summary>
        /// <param name="coords">Coordinates to encode.</param>
        /// <param name="table">The primary or secondary symbole table.</param>
        /// <param name="symbol">The APRS symbol code from the table given.</param>
        /// <param name="amb">Ambiguity to use on the coordinates.</param>
        public Position(GeoCoordinate? coords = null, char? table = null, char? symbol = null, int? amb = null)
        {
            Coordinates = coords ?? Coordinates;
            SymbolTableIdentifier = table ?? SymbolTableIdentifier;
            SymbolCode = symbol ?? SymbolCode;
            Ambiguity = amb ?? Ambiguity;
        }

        /// <summary>
        /// Gets or sets lat/long coordinates.
        /// </summary>
        public GeoCoordinate Coordinates { get; set; } = new GeoCoordinate(0, 0);

        /// <summary>
        /// Gets or sets the symbol table identifier.
        /// </summary>
        public char SymbolTableIdentifier { get; set; } = '\\';

        /// <summary>
        /// Gets or sets the symbol code.
        /// </summary>
        public char SymbolCode { get; set; } = '.';

        /// <summary>
        /// Gets or sets the position ambiguity.
        /// </summary>
        public int Ambiguity { get; set; }

        /// <summary>
        /// Converts a string to have the given amount of ambiguity.
        /// </summary>
        /// <param name="coords">String to convert.</param>
        /// <param name="enforceAmbiguity">Amount of ambiguity to emplace.</param>
        /// <returns>String representing ambiguity.</returns>
        public static string EnforceAmbiguity(string coords, int enforceAmbiguity)
        {
            if (coords == null)
            {
                throw new ArgumentNullException(nameof(coords));
            }
            else if (enforceAmbiguity > coords.Length - 2)
            {
                throw new ArgumentOutOfRangeException(
                    nameof(enforceAmbiguity),
                    $"Only {coords.Length - 2} numbers can be filtered, but {enforceAmbiguity} were requested for string {coords}");
            }

            StringBuilder sb = new StringBuilder(coords);
            int remainingAmbiguity = enforceAmbiguity;

            for (int i = coords.Length - 2; i >= 0 && remainingAmbiguity > 0; --i)
            {
                if (i == coords.Length - 4)
                {
                    continue;
                }

                sb[i] = ' ';
                --remainingAmbiguity;
            }

            return sb.ToString();
        }

        /// <summary>
        /// Counts the number of spaces from right to left and returns the number for ambiguity.
        /// Also ensures there are no spaces left of a digit, which is an invalid APRS ambiguity syntax.
        /// </summary>
        /// <param name="coords">An APRS latitude or longitude string.</param>
        /// <returns>Integer representing ambiguity.</returns>
        public static int CountAmbiguity(string coords)
        {
            if (coords == null)
            {
                throw new ArgumentNullException(nameof(coords));
            }

            int ambiguity = 0;
            bool foundDigit = false;
            for (int i = coords.Length - 2; i >= 0; --i)
            {
                // skip the period
                if (i == coords.Length - 4)
                {
                    continue;
                }
                else if (coords[i] == ' ')
                {
                    if (foundDigit)
                    {
                        throw new ArgumentException(
                            $"Coordinates shoud only have spaces growing from right. Error on string: {coords}",
                            nameof(coords));
                    }
                    else
                    {
                        ++ambiguity;
                    }
                }
                else
                {
                    foundDigit = true;
                }
            }

            return ambiguity;
        }

        /// <summary>
        /// Takes an encoded APRS coordinate string and uses it to initialize to <see cref="GeoCoordinate"/>.
        /// </summary>
        /// <param name="coords">A string of APRS encoded coordinates.</param>
        public void Decode(string coords)
        {
            if (coords == null)
            {
                throw new ArgumentNullException(nameof(coords));
            }

            Match match = Regex.Match(coords, RegexStrings.PositionLatLongWithSymbols);
            match.AssertSuccess("Coordinates", nameof(coords));

            Ambiguity = 0;
            double latitude = DecodeLatitude(match.Groups[1].Value);
            double longitude = DecodeLongitude(match.Groups[3].Value);

            SymbolTableIdentifier = match.Groups[2].Value[0];
            SymbolCode = match.Groups[4].Value[0];
            Coordinates = new GeoCoordinate(latitude, longitude);
        }

        /// <summary>
        /// Decode from Maidenhead Location System gridsquare to a point in the middle of the gridsquare.
        /// </summary>
        /// <param name="gridsquare">4 or 6 char Maidenhead gridsquare, optionally followed by symbol table and identifier.</param>
        public void DecodeMaidenhead(string gridsquare)
        {
            if (gridsquare == null)
            {
                throw new ArgumentNullException(nameof(gridsquare));
            }

            var match = Regex.Match(gridsquare, RegexStrings.MaidenheadGridFullLine);
            match.AssertSuccess("Maidenhead", nameof(gridsquare));

            string trimmedGridsquare = match.Groups[1].Value;

            // If a third group is matched, this group is the two symbol fields.
            if (match.Groups[2].Success)
            {
                string symbols = match.Groups[2].Value;
                SymbolTableIdentifier = symbols[0];
                SymbolCode = symbols[1];
            }

            double latitude = DecodeFromGridsquare(trimmedGridsquare, CoordinateSystem.Latitude);
            double longitude = DecodeFromGridsquare(trimmedGridsquare, CoordinateSystem.Longitude);
            Coordinates = new GeoCoordinate(latitude, longitude);
        }

        /// <summary>
        /// Encodes the location as a gridsquare from the coordinates on this object.
        /// </summary>
        /// <param name="length">Max number of characters to use in gridsquare encoding, must be {4, 6, 8}. Ambiguity will subtract from this.</param>
        /// <param name="appendSymbol">If true, appends the symbol to the end of the string.</param>
        /// <returns>A string representation of the Maidenhead gridsquare.</returns>
        public string EncodeGridsquare(int length, bool appendSymbol)
        {
            if (length != 4 &&
                length != 6 &&
                length != 8)
            {
                throw new ArgumentException($"Length should be 4, 6, or 8. Given value was {length}", nameof(length));
            }
            else if (Coordinates.IsNull)
            {
                throw new NullReferenceException("Coordinates were null.");
            }
            else if (Ambiguity != 0 &&
                     Ambiguity != 2 &&
                     Ambiguity != 4 &&
                     Ambiguity != 6)
            {
                throw new ArgumentException($"Ambiguity must be in {{0, 2, 4, 6}} to encode a gridsquare, but is {Ambiguity}");
            }
            else if (Ambiguity > length - 4)
            {
                throw new ArgumentException($"Final length of the string must be at least 4. length - Ambiguity >= 4, but length is {length} and Ambiguity is {Ambiguity}");
            }

            string gridsquare = string.Empty;

            string longitude = EncodeGridsquareElement(Coordinates.Longitude, CoordinateSystem.Longitude, (length - Ambiguity) / 2);
            string latitude = EncodeGridsquareElement(Coordinates.Latitude, CoordinateSystem.Latitude, (length - Ambiguity) / 2);

            for (int i = 0; i < length - Ambiguity; ++i)
            {
                if (i % 2 == 0)
                {
                    gridsquare += longitude[i / 2];
                }
                else
                {
                    gridsquare += latitude[i / 2];
                }
            }

            if (appendSymbol)
            {
                gridsquare += SymbolTableIdentifier;
                gridsquare += SymbolCode;
            }

            return gridsquare;
        }

        /// <summary>
        /// Decode the latitude section of the coordinate string.
        /// </summary>
        /// <param name="coords">APRS encoded latitude coordinates.</param>
        /// <returns>Decimal latitude.</returns>
        public double DecodeLatitude(string coords)
        {
            // Ensure latitude is well formatted
            if (coords == null)
            {
                throw new ArgumentNullException(nameof(coords));
            }

            string upperCoords = coords.ToUpperInvariant();
            if (upperCoords.Length != 8)
            {
                throw new ArgumentException("Latitude coordinates should be 8 characters. Given string was " + upperCoords.Length);
            }

            char direction = upperCoords[7];
            if (upperCoords[7] != 'N' && upperCoords[7] != 'S')
            {
                throw new ArgumentException("Coordinates should end in N or S. Given string ended in " + direction);
            }

            if (upperCoords[4] != '.')
            {
                throw new ArgumentException("Coordinates should have '.' at index 4. Given string had " + upperCoords[4]);
            }

            // Count ambiguity and ensure no out of place spaces
            Ambiguity = CountAmbiguity(upperCoords);

            // replace spaces with zeros
            string modifiedCoords = upperCoords.Replace(' ', '0');

            // Break out strings and convert values
            string degreesStr = modifiedCoords.Substring(0, 2);
            string minutesStr = modifiedCoords.Substring(2, 2);
            string hundMinutesStr = modifiedCoords.Substring(5, 2);

            double degrees = double.Parse(degreesStr, CultureInfo.InvariantCulture);
            if (degrees < 00 || degrees > 90)
            {
                throw new ArgumentOutOfRangeException(
                    nameof(coords),
                    $"Degrees must be in range [00,90]. Found: {degrees} in coord string {modifiedCoords} from given coord string {coords}");
            }

            double minutes = double.Parse(minutesStr, CultureInfo.InvariantCulture);
            double hundMinutes = double.Parse(hundMinutesStr, CultureInfo.InvariantCulture);

            double retval = degrees + ((minutes + (hundMinutes / 100)) / 60.0);

            if (direction == 'S')
            {
                retval *= -1;
            }

            // limit significant figures
            return Math.Round(retval, 4);
        }

        /// <summary>
        /// Decode the longitude section of the coordinate string.
        /// </summary>
        /// <param name="coords">APRS encoded latitude coordinates.</param>
        /// <returns>Decimal longitude.</returns>
        public double DecodeLongitude(string coords)
        {
            if (coords == null)
            {
                throw new ArgumentNullException(nameof(coords));
            }
            else if (coords.Length != 9)
            {
                throw new ArgumentException(
                    $"Longitude format calls for 9 characters. Given string has length {coords.Length}",
                    nameof(coords));
            }

            string upperCoords = coords.ToUpperInvariant();

            char direction = upperCoords[8];
            if (direction != 'W' && direction != 'E')
            {
                throw new ArgumentException(
                    $"Coordinates should end in E or W. Given string ended in {direction}",
                    nameof(coords));
            }
            else if (upperCoords[5] != '.')
            {
                throw new ArgumentException(
                    $"Coordinates should have '.' at index 5. Given string had {upperCoords[5]}",
                    nameof(coords));
            }

            // This ensures no spaces are out of place
            CountAmbiguity(upperCoords);

            // Enforce ambiguity from latitude and replace spaces
            string modifiedCoords = EnforceAmbiguity(upperCoords, Ambiguity);
            modifiedCoords = modifiedCoords.Replace(' ', '0');

            // Break out strings and convert values
            string degreesStr = modifiedCoords.Substring(0, 3);
            string minutesStr = modifiedCoords.Substring(3, 2);
            string hundMinuteStr = modifiedCoords.Substring(6, 2);

            double degrees = double.Parse(degreesStr, CultureInfo.InvariantCulture);
            if (degrees < 000 || degrees > 180)
            {
                throw new ArgumentOutOfRangeException(
                    nameof(coords),
                    $"Degrees longitude must be in range [000, 180]. Found: {degrees} in coord string {modifiedCoords} from given coord string {coords}");
            }

            double minutes = double.Parse(minutesStr, CultureInfo.InvariantCulture);
            double hundMinutes = double.Parse(hundMinuteStr, CultureInfo.InvariantCulture);

            double retval = degrees + ((minutes + (hundMinutes / 100)) / 60.0);

            if (direction == 'W')
            {
                retval *= -1;
            }

            // limit significant figures
            return Math.Round(retval, 4);
        }

        /// <summary>
        /// Encodes with the values set on the fields using lat/long.
        /// </summary>
        /// <returns>A string encoding of an APRS position (lat/long).</returns>
        public string Encode()
        {
            return EncodeLatitude() +
                SymbolTableIdentifier +
                EncodeLongitude() +
                SymbolCode;
        }

        /// <summary>
        /// Encodes latitude position, enforcing ambiguity
        /// Really just calls through to EncodeCoordinates, but this is easier to test
        /// with PrivateObject.
        /// </summary>
        /// <returns>Encoded APRS latitude position.</returns>
        public string EncodeLatitude()
        {
            return EncodeCoordinates(CoordinateSystem.Latitude);
        }

        /// <summary>
        /// Encodes longitude position, enforcing ambiguity
        /// Really just calls through to EncodeCoordinates, but this is easier to test
        /// with PrivateObject.
        /// </summary>
        /// <returns>Encoded APRS longitude position.</returns>
        public string EncodeLongitude()
        {
            return EncodeCoordinates(CoordinateSystem.Longitude);
        }

        /// <summary>
        /// Encode either the latitude or longitude part of a Maidenhead gridsquare.
        /// </summary>
        /// <param name="coords">The coordinates to use.</param>
        /// <param name="coordinateEncode">Either latitude or longitude encoding.</param>
        /// <param name="length">The number of chars for this encoding (should be half the total length of the gridsquare as this is only lat or long).</param>
        /// <returns>A string of the lat or long for the gridsquare encoding.</returns>
        private static string EncodeGridsquareElement(double coords, CoordinateSystem coordinateEncode, int length)
        {
            if (coordinateEncode == CoordinateSystem.Longitude && Math.Abs(coords) > 180)
            {
                throw new ArgumentOutOfRangeException(nameof(coords), "Longitude coordinates must be inside [-180, 180]");
            }
            else if (coordinateEncode == CoordinateSystem.Latitude && Math.Abs(coords) > 90)
            {
                throw new ArgumentOutOfRangeException(nameof(coords), "Longitude coordinates must be inside [-90, 90]");
            }

            string encoded = string.Empty;
            int charIndex;
            double stepDivisor;

            // Longitude scales most values by 2 over latitude
            int multiplier = (coordinateEncode == CoordinateSystem.Longitude) ? 2 : 1;

            // no negatives here
            double shiftedCoords = coords + (multiplier * 90.0);

            if (length >= 1)
            {
                stepDivisor = 10.0 * multiplier;
                charIndex = (int)(shiftedCoords / stepDivisor);
                encoded += char.ConvertFromUtf32('A' + charIndex);

                shiftedCoords %= stepDivisor;
            }

            if (length >= 2)
            {
                stepDivisor = 1.0 * multiplier;
                charIndex = (int)(shiftedCoords / stepDivisor);
                encoded += charIndex.ToString(CultureInfo.InvariantCulture);

                shiftedCoords %= stepDivisor;
            }

            // moving in to minutes territory
            shiftedCoords *= 60.0;

            if (length >= 3)
            {
                stepDivisor = 2.5 * multiplier;
                charIndex = (int)(shiftedCoords / stepDivisor);
                encoded += char.ConvertFromUtf32('A' + charIndex);

                shiftedCoords %= stepDivisor;
            }

            if (length >= 4)
            {
                stepDivisor = 0.25 * multiplier;
                charIndex = (int)(shiftedCoords / stepDivisor);
                encoded += charIndex.ToString(CultureInfo.InvariantCulture);

                // shiftedCoords %= stepDivisor;
            }

            return encoded;
        }

        /// <summary>
        /// Given a maidenhead gridsquare, converts to decimal latitude or longitude.
        /// </summary>
        /// <param name="gridsquare">A Maidenhead Location System gridsquare.</param>
        /// <param name="coordinateDecode">Latitude or longitude for decode.</param>
        /// <returns>A double representing latitude [-180.0, 180.0] or longitude [-90.0, 90.0].</returns>
        private static double DecodeFromGridsquare(string gridsquare, CoordinateSystem coordinateDecode)
        {
            if (gridsquare == null)
            {
                throw new ArgumentNullException(nameof(gridsquare));
            }
            else if (gridsquare.Length != 4 &&
                     gridsquare.Length != 6 &&
                     gridsquare.Length != 8)
            {
                throw new ArgumentException(
                    $"The given Maidenhead Location System gridsquare was {gridsquare.Length} characters length when a length of 4 or 6 characters was expected.",
                    nameof(gridsquare));
            }

            double coord = 0;
            double gridsquareSize = 0;

            // Chars alternatingly refer to longitude and latitude
            int index = (coordinateDecode == CoordinateSystem.Latitude) ? 1 : 0;

            // Longitude scales most values by 2 over latitude
            int multiplier = (coordinateDecode == CoordinateSystem.Longitude) ? 2 : 1;

            gridsquare = gridsquare.ToUpperInvariant();

            if (gridsquare.Length >= 2)
            {
                if (gridsquare[index] < 'A' || gridsquare[index] > 'R')
                {
                    throw new ArgumentException(
                        $"Gridsquare should use A-R for first pair of characters. The given string was {gridsquare}",
                        nameof(gridsquare));
                }

                gridsquareSize = 10 * multiplier;
                coord += gridsquareSize * (gridsquare[index] - 'A');
                index += 2;
            }

            if (gridsquare.Length >= 4)
            {
                if (!char.IsDigit(gridsquare[index]))
                {
                    throw new ArgumentException(
                        $"Gridsquare should use 0-9 for second pair of characters. The given string was {gridsquare}",
                        nameof(gridsquare));
                }

                gridsquareSize = multiplier;
                coord += gridsquareSize * char.GetNumericValue(gridsquare[index]);
                index += 2;
            }

            if (gridsquare.Length >= 6)
            {
                if (gridsquare[index] < 'A' || gridsquare[index] > 'X')
                {
                    throw new ArgumentException(
                        $"Gridsquare should use A-X for third pair of characters. The given string was {gridsquare}",
                        nameof(gridsquare));
                }

                gridsquareSize = (2.5 * multiplier) / 60.0;
                coord += ((2.5 * multiplier) * (gridsquare[index] - 'A')) / 60.0;
                index += 2;
            }

            if (gridsquare.Length >= 8)
            {
                if (!char.IsDigit(gridsquare[index]))
                {
                    throw new ArgumentException(
                        $"Gridsquare should use 0-9 for fourth pair of characters. The given string was {gridsquare}",
                        nameof(gridsquare));
                }

                gridsquareSize = (0.25 * multiplier) / 60.0;
                coord += ((0.25 * multiplier) * char.GetNumericValue(gridsquare[index])) / 60.0;
            }

            // center the coordinate in the grid
            coord += gridsquareSize / 2.0;
            return coord - (90.0 * multiplier);
        }

        /// <summary>
        /// Encodes latitude or longitude position, enforcing ambiguity.
        /// </summary>
        /// <param name="type"><see cref="CoordinateSystem"/> to encode: Latitude or Longitude.</param>
        /// <returns>String of APRS latitude or longitude position.</returns>
        private string EncodeCoordinates(CoordinateSystem type)
        {
            double coords;
            char direction;
            string decimalFormat;

            if (type == CoordinateSystem.Latitude)
            {
                coords = Coordinates.Latitude;
                direction = coords < 0 ? 'S' : 'N';
                decimalFormat = "D2";
            }
            else if (type == CoordinateSystem.Longitude)
            {
                coords = Coordinates.Longitude;
                direction = coords > 0 ? 'E' : 'W';
                decimalFormat = "D3";
            }
            else
            {
                throw new ArgumentException($"Invalid CoordinateSystem: {type}", nameof(type));
            }

            coords = Math.Abs(coords);

            int degrees = (int)Math.Floor(coords);
            int minutes = (int)Math.Floor((coords - degrees) * 60);
            int hundMinutes = (int)Math.Round((((coords - degrees) * 60) - minutes) * 100);

            string encoded = degrees.ToString(decimalFormat, CultureInfo.InvariantCulture) +
                minutes.ToString("D2", CultureInfo.InvariantCulture) +
                '.' +
                hundMinutes.ToString("D2", CultureInfo.InvariantCulture) +
                direction;

            encoded = EnforceAmbiguity(encoded, Ambiguity);

            return encoded;
        }
    }
}

namespace AprsSharp.Parsers.Aprs
{
    /// <summary>
    /// Holds strings for regex comparisons for reuse.
    /// </summary>
    internal static class RegexStrings
    {
        /// <summary>
        /// Same as <see cref="MaidenheadGridWithSymbols"/> but symbols are optional.
        /// Three groups: Full, alphanumeric grid, symbols.
        /// </summary>
        public static readonly string MaidenheadGridWithOptionalSymbols = $@"{MaidenheadGridWithSymbols}?";

        /// <summary>
        /// Matches Maidenhead grid with symbols.
        /// Three groups: Full, alphanumeric grid, symbols.
        /// </summary>
        public const string MaidenheadGridWithSymbols = @"([a-zA-Z0-9]{4,8})(.{2})";

        /// <summary>
        /// Matches Maidenhead grid of 4-6 characters without symbols.
        /// One group: Full match.
        /// </summary>
        public const string MaidenheadGrid4or6NoSymbols = @"([a-zA-Z0-9]{4,6})";

        /// <summary>
        /// Matchdes a lat/long position, including with ambiguity, including symbols.
        /// Five matches:
        ///     Full
        ///     Latitute
        ///     Symbol table ID
        ///     Longitude
        ///     Symbol Code.
        /// </summary>
        public const string PositionLatLongWithSymbols = @"([0-9 \.NS]{8})(.)([0-9 \.EW]{9})(.)";

        /// <summary>
        /// Same as <see cref="MaidenheadGridWithOptionalSymbols"/> but forces full line match.
        /// </summary>
        /// <value></value>
        public static readonly string MaidenheadGridFullLine = $@"^{MaidenheadGridWithOptionalSymbols}$";

        /// <summary>
        /// Matches a Miadenhead Grid in square brackets [] with comment.
        /// Four groups: Full, alphanumeric grid, comment.
        /// </summary>
        public static readonly string MaidenheadGridLocatorBeacon = $@"^\[{MaidenheadGrid4or6NoSymbols}\](.+)?$";

        /// <summary>
        /// Matches a Status info field with Maidenhead grid and optional comment (comment separated by a space)
        /// Four matches: Full, full maidenhead, alphanumeric grid, symbols, comment.
        /// </summary>
        public static readonly string StatusWithMaidenheadAndComment = $@"^>({MaidenheadGridWithSymbols}) ?(.+)?$";

        /// <summary>
        /// Matches a PositionWithoutTimestamp info field.
        /// Seven mathces:
        ///     Full
        ///     Full lat/long coords and symbols
        ///     Latitude
        ///     Symbol table
        ///     Longitude
        ///     Symbol code
        ///     Optional comment.
        /// </summary>
        public static readonly string PositionWithoutTimestamp = $@"^[!=]({PositionLatLongWithSymbols})(.+)?$";

        /// <summary>
        /// Matches a PositionWithTimestamp info field.
        /// 9 mathces:
        ///     Full
        ///     Packet type symbol (/ or @)
        ///     Timestamp
        ///     Full position
        ///         Latitude
        ///         Symbol table
        ///         Longitude
        ///         Symbol code
        ///     Optional comment.
        /// </summary>
        public static readonly string PositionWithTimestamp = $@"^([/@])([0-9]{{6}}[/zh0-9])({PositionLatLongWithSymbols})(.+)?$";

        /// <summary>
        /// Matches a full TNC2-encoded packet.
        /// 4 matches:
        ///     Full
        ///     Sender callsign
        ///     Path
        ///     Info field.
        /// </summary>
        public const string Tnc2Packet = @"^([^>]+)>([^:]+):(.+)$";
    }
}

namespace AprsSharp.Parsers.Aprs
{
    using System;
    using System.Globalization;

    /// <summary>
    /// Represents, encodes, and decodes an APRS timestamp.
    /// </summary>
    public class Timestamp
    {
        /// <summary>
        /// Initializes a new instance of the <see cref="Timestamp"/> class
        /// by decoding the given timestamp in to it.
        /// </summary>
        /// <param name="timestamp">APRS timestamp.</param>
        public Timestamp(string timestamp)
        {
            Decode(timestamp);
        }

        /// <summary>
        /// Initializes a new instance of the <see cref="Timestamp"/> class
        /// given a <see cref="DateTime"/> object.
        /// </summary>
        /// <param name="dt">DateTime object to use for this Timestamp.</param>
        public Timestamp(DateTime dt)
        {
            DateTime = dt;
        }

        /// <summary>
        /// Gets or sets a <see cref="DateTime"/> representing the APRS timestamp.
        /// </summary>
        public DateTime DateTime { get; set; } = DateTime.UtcNow;

        /// <summary>
        /// Gets or sets a <see cref="TimestampType"/> representing the APRS timestamp type.
        /// </summary>
        public TimestampType DecodedType { get; set; } = TimestampType.NotDecoded;

        /// <summary>
        /// If we're given a time in DHM, we need to find the correct month and year to fill in for DateTime.
        /// Assuming the day is in the past and the most recent occurance of that day in a month, this function finds the year and month.
        /// </summary>
        /// <param name="day">The day number to find.</param>
        /// <param name="hint">A hint to the timeframe we're looking for. Generally, DateTime.Now.</param>
        /// <param name="year">The year in which the most recent occurance of that day number occured.</param>
        /// <param name="month">The month in which the most recent occurance of that day number occured.</param>
        public static void FindCorrectYearAndMonth(
            int day,
            DateTime hint,
            out int year,
            out int month)
        {
            if (day > 31 || day < 1)
            {
                throw new ArgumentOutOfRangeException("Day must be in range [1, 31], but the passed in day number was " + day);
            }

            int currYear = hint.Year;
            int currMonth = hint.Month;
            int currDay = hint.Day;

            // If that day number has already happened this month, we're done!
            if (day <= currDay)
            {
                year = currYear;
                month = currMonth;
            }
            else
            {
                DateTime itrDate = hint;
                while (itrDate.Day != day)
                {
                    itrDate = itrDate.AddDays(-1);
                }

                month = itrDate.Month;
                year = itrDate.Year;
            }
        }

        /// <summary>
        /// Given an hour, minute, and second, determines if this should be the same day as the hint (generally DateTime.Now) or the day before.
        /// </summary>
        /// <param name="hour">Hour of packet.</param>
        /// <param name="minute">Minute of packet.</param>
        /// <param name="second">Second of packet.</param>
        /// <param name="hint">Usually DateTime.Now.</param>
        /// <param name="day">Found day.</param>
        /// <param name="month">Found month.</param>
        /// <param name="year">Found year.</param>
        public static void FindCorrectDayMonthAndYear(
            int hour,
            int minute,
            int second,
            DateTime hint,
            out int day,
            out int month,
            out int year)
        {
            DateTime packetTime = new DateTime(hint.Year, hint.Month, hint.Day, hour, minute, second, hint.Kind);
            TimeSpan diff = hint.Subtract(packetTime);

            // allow for a few minutes of clock drift between receiver and transmitter
            if (diff.TotalMinutes < -5)
            {
                // assume it's from yesterday
                packetTime = packetTime.AddDays(-1);
            }

            day = packetTime.Day;
            month = packetTime.Month;
            year = packetTime.Year;
        }

        /// <summary>
        /// Given a month, day, hour, and minute, determines the appropriate year (in the past as determiend by hint).
        /// </summary>
        /// <param name="month">Packet month.</param>
        /// <param name="day">Packet day.</param>
        /// <param name="hour">Packet hour.</param>
        /// <param name="minute">Packet minute.</param>
        /// <param name="hint">Usually DateTime.Now.</param>
        /// <param name="year">Found year.</param>
        public static void FindCorrectYear(
            int month,
            int day,
            int hour,
            int minute,
            DateTime hint,
            out int year)
        {
            DateTime packet;
            int numRetries = 0;

            // Retries on four separate years to find a year
            // that could match the day and month (incl. leap)
            while (true)
            {
                try
                {
                    packet = new DateTime(
                        hint.Year - numRetries,
                        month,
                        day,
                        hour,
                        minute,
                        0,
                        hint.Kind);

                    // again, use five minute clock drift
                    if (hint.Subtract(packet).TotalMinutes > -5)
                    {
                        break;
                    }

                    ++numRetries;
                }
                catch (ArgumentOutOfRangeException)
                {
                    ++numRetries;
                    if (numRetries >= 4)
                    {
                        throw;
                    }
                }
            }

            year = packet.Year;
        }

        /// <summary>
        /// Encodes the data from the stored datetime as a string with the requested type.
        /// </summary>
        /// <param name="type">The APRS <see cref="TimestampType"/>.</param>
        /// <returns>String. 7-8 characters as defined by the APRS spec.</returns>
        public string Encode(TimestampType type)
        {
            return type switch
            {
                TimestampType.DHMz => EncodeDHM(isZulu: true),
                TimestampType.DHMl => EncodeDHM(isZulu: false),
                TimestampType.HMS => EncodeHMS(),
                TimestampType.MDHM => EncodeMDHM(),
                TimestampType.NotDecoded => throw new ArgumentOutOfRangeException(nameof(type), $"Cannot encode to type: {TimestampType.NotDecoded}"),
                _ => throw new NotSupportedException(),
            };
        }

        /// <summary>
        /// Takes the APRS Time Format string, detects the formatting, and decodes it in to this object.
        /// </summary>
        /// <param name="timestamp">APRS timestamp.</param>
        public void Decode(string timestamp)
        {
            if (timestamp == null)
            {
                throw new ArgumentNullException(nameof(timestamp));
            }
            else if (timestamp.Length != 7 && timestamp.Length != 8)
            {
                throw new ArgumentException("The given APRS timestamp is " + timestamp.Length + " characters long instead of the required 7-8.");
            }

            char timeIndicator = timestamp[6];

            if (timeIndicator == 'z' || timeIndicator == '/')
            {
                DecodeDHM(timestamp);
            }
            else if (timeIndicator == 'h')
            {
                DecodeHMS(timestamp);
            }
            else if (char.IsNumber(timeIndicator))
            {
                DecodeMDHM(timestamp);
            }
            else
            {
                throw new ArgumentException("timestamp was not a valid APRS format (did not have a valid Time Indicator character)");
            }
        }

        /// <summary>
        /// Encodes to a Day/Hour/Minute (DHM) string in zulu time or local time.
        /// </summary>
        /// <param name="isZulu">If true, encodes in DHM with zulu time, else local time. Zulu time should be used.</param>
        /// <returns>DHM encoded APRS timestamp string.</returns>
        private string EncodeDHM(bool isZulu)
        {
            string encodedPacket = string.Empty;
            DateTime convertedDateTime = isZulu ? DateTime.ToUniversalTime() : DateTime.ToLocalTime();

            // Add day
            encodedPacket += convertedDateTime.Day.ToString("D2", CultureInfo.InvariantCulture);

            // Add hour
            encodedPacket += convertedDateTime.Hour.ToString("D2", CultureInfo.InvariantCulture);

            // Add minute
            encodedPacket += convertedDateTime.Minute.ToString("D2", CultureInfo.InvariantCulture);

            // Add time indicator
            encodedPacket += isZulu ? "z" : "/";

            return encodedPacket;
        }

        /// <summary>
        /// Encodes to an Hour/Minute/Second (HMS) string in zulu time.
        /// </summary>
        /// <returns>HMS encoded APRS timestamp string.</returns>
        private string EncodeHMS()
        {
            string encodedPacket = string.Empty;
            DateTime convertedDateTime = DateTime.ToUniversalTime();

            // Add hour
            encodedPacket += convertedDateTime.Hour.ToString("D2", CultureInfo.InvariantCulture);

            // Add minute
            encodedPacket += convertedDateTime.Minute.ToString("D2", CultureInfo.InvariantCulture);

            // Add second
            encodedPacket += convertedDateTime.Second.ToString("D2", CultureInfo.InvariantCulture);

            // Add the time indicator
            encodedPacket += "h";

            return encodedPacket;
        }

        /// <summary>
        /// Encodes to an Month/Day/Hour/Minute (MDHM) string in zulu time.
        /// </summary>
        /// <returns>MDHM encoded APRS timestamp string.</returns>
        private string EncodeMDHM()
        {
            string encodedPacket = string.Empty;
            DateTime convertedDateTime = DateTime.ToUniversalTime();

            // Add month
            encodedPacket += convertedDateTime.Month.ToString("D2", CultureInfo.InvariantCulture);

            // Add day
            encodedPacket += convertedDateTime.Day.ToString("D2", CultureInfo.InvariantCulture);

            // Add hour
            encodedPacket += convertedDateTime.Hour.ToString("D2", CultureInfo.InvariantCulture);

            // Add minute
            encodedPacket += convertedDateTime.Minute.ToString("D2", CultureInfo.InvariantCulture);

            return encodedPacket;
        }

        /// <summary>
        /// Takes a Day/Hours/Minutes formatted APRS timestamp and decodes it in to this object.
        /// </summary>
        /// <param name="timestamp">Day/Hours/Minutes formatted APRS timestamp.</param>
        private void DecodeDHM(string timestamp)
        {
            if (timestamp == null)
            {
                throw new ArgumentNullException(nameof(timestamp));
            }
            else if (timestamp.Length != 7)
            {
                throw new ArgumentException("timestamp is not in APRS DHM datetime format. Length is " + timestamp.Length + " when it should be 7");
            }

            char timeIndicator = timestamp[6];

            bool wasZuluTime;
            if (timeIndicator == 'z')
            {
                wasZuluTime = true;
            }
            else if (timeIndicator == '/')
            {
                wasZuluTime = false;
            }
            else
            {
                throw new ArgumentException("timestamp is not in DHM format as time indicator is " + timeIndicator + " when it should be z or /");
            }

            DecodedType = wasZuluTime ? TimestampType.DHMz : TimestampType.DHMl;
            if (!int.TryParse(timestamp.Substring(0, 6), out _))
            {
                throw new ArgumentException("timestamp contained non-numeric values in the first 6 spaces: " + timestamp);
            }

            string dayStr = timestamp.Substring(0, 2);
            string hourStr = timestamp.Substring(2, 2);
            string minuteStr = timestamp.Substring(4, 2);

            int day = int.Parse(dayStr, CultureInfo.InvariantCulture);
            int hour = int.Parse(hourStr, CultureInfo.InvariantCulture);
            int minute = int.Parse(minuteStr, CultureInfo.InvariantCulture);
            DateTime hint = wasZuluTime ? DateTime.UtcNow : DateTime.Now;

            FindCorrectYearAndMonth(day, hint, out int year, out int month);
            DateTimeKind dtKind = wasZuluTime ? DateTimeKind.Utc : DateTimeKind.Local;

            DateTime = new DateTime(year, month, day, hour, minute, 0, dtKind);
        }

        /// <summary>
        /// Takes an Hours/Minutes/Seconds formatted APRS timestamp and decodes it in to this object.
        /// </summary>
        /// <param name="timestamp">Hours/Minutes/Seconds formatted APRS timestamp.</param>
        private void DecodeHMS(string timestamp)
        {
            if (timestamp == null)
            {
                throw new ArgumentNullException(nameof(timestamp));
            }
            else if (timestamp.Length != 7 || timestamp[6] != 'h')
            {
                throw new ArgumentException("timestamp is not in APRS HMS datetime format. Length is " + timestamp.Length + " when it should be 7 or format marker is " + timestamp[6] + " when it should be 'h'");
            }

            DecodedType = TimestampType.HMS;

            string hourStr = timestamp.Substring(0, 2);
            string minuteStr = timestamp.Substring(2, 2);
            string secondStr = timestamp.Substring(4, 2);

            int hour = int.Parse(hourStr, CultureInfo.InvariantCulture);
            int minute = int.Parse(minuteStr, CultureInfo.InvariantCulture);
            int second = int.Parse(secondStr, CultureInfo.InvariantCulture);
            DateTime hint = DateTime.UtcNow;

            FindCorrectDayMonthAndYear(
                hour,
                minute,
                second,
                hint,
                out int day,
                out int month,
                out int year);

            DateTimeKind dtKind = DateTimeKind.Utc;
            DateTime = new DateTime(year, month, day, hour, minute, second, dtKind);
        }

        /// <summary>
        /// Takes a Month/Day/Hours/Minutes formmatted APRS timestamp and decodes it in to this object.
        /// </summary>
        /// <param name="timestamp">Month/Day/Hours/Minutes forammted APRS timestamp.</param>
        private void DecodeMDHM(string timestamp)
        {
            if (timestamp == null)
            {
                throw new ArgumentNullException(nameof(timestamp));
            }
            else if (timestamp.Length != 8)
            {
                throw new ArgumentException("timestamp is not in APRS MDHM datetime format. Length is " + timestamp.Length + " when it should be 8");
            }

            DecodedType = TimestampType.MDHM;

            string monthStr = timestamp.Substring(0, 2);
            string dayStr = timestamp.Substring(2, 2);
            string hourStr = timestamp.Substring(4, 2);
            string minuteStr = timestamp.Substring(6, 2);

            int month = int.Parse(monthStr, CultureInfo.InvariantCulture);
            int day = int.Parse(dayStr, CultureInfo.InvariantCulture);
            int hour = int.Parse(hourStr, CultureInfo.InvariantCulture);
            int minute = int.Parse(minuteStr, CultureInfo.InvariantCulture);
            DateTime hint = DateTime.UtcNow;

            FindCorrectYear(
                month,
                day,
                hour,
                minute,
                hint,
                out int year);

            DateTimeKind dtKind = DateTimeKind.Utc;
            DateTime = new DateTime(year, month, day, hour, minute, 0 /* second */, dtKind);
        }
    }
}