using System.Text;
using System.Text.RegularExpressions;

namespace QuantumConcepts.Formats.StereoLithography
{
    /// <summary>The outer-most STL object which contains the <see cref="Facets"/> that make up the model.</summary>
    public class STLDocument : IEquatable<STLDocument>, IEnumerable<Facet>
    {
        /// <summary>Defines the buffer size to use when reading from a <see cref="StreamReader"/>.</summary>
        private const int DefaultBufferSize = 1024;

        /// <summary>The name of the solid.</summary>
        /// <remarks>This property is not used for binary STLs.</remarks>
        public string? Name { get; set; } = null;

        /// <summary>The list of <see cref="Facet"/>s within this solid.</summary>
        public IList<Facet> Facets { get; set; } = new List<Facet>();

        /// <summary>Creates a new, empty <see cref="STLDocument"/>.</summary>
        public STLDocument() { }

        /// <summary>Creates a new <see cref="STLDocument"/> with the given <paramref name="name"/> and populated with the given <paramref name="facets"/>.</summary>
        /// <param name="name">
        ///     The name of the solid.
        ///     <remarks>This property is not used for binary STLs.</remarks>
        /// </param>
        /// <param name="facets">The facets with which to populate this solid.</param>
        public STLDocument(string name, IEnumerable<Facet> facets) : this()
        {
            Name = name;
            Facets = facets.ToList();
        }

        /// <summary>Writes the <see cref="STLDocument"/> as text to the provided <paramref name="stream"/>.</summary>
        /// <param name="stream">The stream to which the <see cref="STLDocument"/> will be written.</param>
        public void WriteText(Stream stream)
        {
            if (stream == null) throw new NullReferenceException(nameof(stream));

            using (var writer = new StreamWriter(stream, Encoding.ASCII, DefaultBufferSize, true))
            {
                // Write the header.
                writer.WriteLine(this);

                // Write each facet.
                Facets.ForEach(o => o.Write(writer));

                // Write the footer.
                writer.Write($"end{this}");
            }
        }

        /// <summary>Writes the <see cref="STLDocument"/> as binary to the provided <paramref name="stream"/>.</summary>
        /// <param name="stream">The stream to which the <see cref="STLDocument"/> will be written.</param>
        public void WriteBinary(Stream stream)
        {
            if (stream == null) throw new NullReferenceException(nameof(stream));

            using (var writer = new BinaryWriter(stream, Encoding.ASCII, true))
            {
                byte[] header = Encoding.ASCII.GetBytes("Binary STL generated by STLdotNET");
                byte[] headerFull = new byte[80];

                Buffer.BlockCopy(header, 0, headerFull, 0, Math.Min(header.Length, headerFull.Length));

                // Write the header and facet count.
                writer.Write(headerFull);
                writer.Write((UInt32)Facets.Count);

                // Write each facet.
                Facets.ForEach(o => o.Write(writer));
            }
        }

        /// <summary>Writes the <see cref="STLDocument"/> as text to the provided <paramref name="path"/>.</summary>
        /// <param name="path">The absolute path where the <see cref="STLDocument"/> will be written.</param>
        public void SaveAsText(string path)
        {
            CreatePathDirectories(path);

            using (var stream = File.Create(path))
            {
                WriteText(stream);
            }
        }

        /// <summary>Writes the <see cref="STLDocument"/> as binary to the provided <paramref name="path"/>.</summary>
        /// <param name="path">The absolute path where the <see cref="STLDocument"/> will be written.</param>
        public void SaveAsBinary(string path)
        {
            CreatePathDirectories(path);

            using (var stream = File.Create(path))
            {
                WriteBinary(stream);
            }
        }

        private void CreatePathDirectories(string path)
        {
            if (path.IsNullOrEmpty()) throw new ArgumentNullException("path");

            var dir = Path.GetDirectoryName(path);

            if (dir == null) throw new InvalidOperationException($"Could not determine directory name for path: {path}");

            // Create dir(s).
            Directory.CreateDirectory(dir);
        }

        /// <summary>Appends the provided facets to this instance's <see cref="Facets"/>.</summary>
        /// <remarks>An entire <see cref="STLDocument"/> can be passed to this method and all of the facets which it contains will be appended to this instance.</remarks>
        /// <param name="facets">The facets to append.</param>
        public void AppendFacets(IEnumerable<Facet> facets)
        {
            foreach (var facet in facets)
            {
                Facets.Add(facet);
            }
        }

        /// <summary>Determines if the <see cref="STLDocument"/> contained within the <paramref name="stream"/> is text-based.</summary>
        /// <remarks>The <paramref name="stream"/> will be reset to position 0.</remarks>
        /// <param name="stream">The stream which contains the STL data.</param>
        /// <returns>True if the <see cref="STLDocument"/> is text-based, otherwise false.</returns>
        public static bool IsText(Stream stream)
        {
            if (stream == null) throw new NullReferenceException(nameof(stream));

            const string solid = "solid";

            byte[] buffer = new byte[5];
            string header;

            // Reset the stream to tbe beginning and read the first few bytes, then reset the stream to the beginning again.
            stream.Seek(0, SeekOrigin.Begin);
            stream.Read(buffer, 0, buffer.Length);
            stream.Seek(0, SeekOrigin.Begin);

            // Read the header as ASCII.
            header = Encoding.ASCII.GetString(buffer);

            return solid.Equals(header, StringComparison.InvariantCultureIgnoreCase);
        }

        /// <summary>Determines if the <see cref="STLDocument"/> contained within the <paramref name="stream"/> is binary-based.</summary>
        /// <remarks>The <paramref name="stream"/> will be reset to position 0.</remarks>
        /// <param name="stream">The stream which contains the STL data.</param>
        /// <returns>True if the <see cref="STLDocument"/> is binary-based, otherwise false.</returns>
        public static bool IsBinary(Stream stream)
        {
            return !IsText(stream);
        }

        /// <summary>Reads the <see cref="STLDocument"/> contained within the <paramref name="stream"/> into a new <see cref="STLDocument"/>.</summary>
        /// <remarks>This method will determine how to read the <see cref="STLDocument"/> (whether to read it as text or binary data).</remarks>
        /// <param name="stream">The stream which contains the STL data.</param>
        /// <param name="tryBinaryIfTextFailed">Set to true to try read as binary if reading as text results in zero facets</param>
        /// <returns>An <see cref="STLDocument"/> representing the data contained in the stream or null if the stream is empty.</returns>
        public static STLDocument Read(Stream stream, bool tryBinaryIfTextFailed = false)
        {
            if (stream == null) throw new NullReferenceException(nameof(stream));

            // Determine if the stream contains a text-based or binary-based <see cref="STLDocument"/>, and then read it.
            var isText = IsText(stream);
            STLDocument? textDoc = null;
            STLDocument? binaryDoc = null;
            STLDocument? finalDoc = null;

            if (isText)
            {
                using (var reader = new StreamReader(stream, Encoding.ASCII, true, DefaultBufferSize, true))
                {
                    textDoc = Read(reader);
                }

                if (textDoc.Facets.Count > 0 || !tryBinaryIfTextFailed)
                {
                    return textDoc;
                }
            }

            // Try binary if zero Facets were read and `tryBinaryIfTextFailed == true`.
            if (!isText || (textDoc?.Facets.Count == 0 && tryBinaryIfTextFailed))
            {
                // Make sure we're at the beginning of the stream (in case we tried to read it as text above).
                stream.Seek(0, SeekOrigin.Begin);

                using (var reader = new BinaryReader(stream, Encoding.ASCII, true))
                {
                    binaryDoc = Read(reader);
                }
            }

            // Use text document if binary reading also failed.
            finalDoc = (binaryDoc?.Facets.Count > 0 || !isText) ? binaryDoc : textDoc;

            // Make sure we have a text or binary document.
            if (finalDoc == null) throw new InvalidOperationException("Could not read stream as text or binary STL document.");

            return finalDoc;
        }

        /// <summary>Reads the STL document contained within the <paramref name="reader"/> into a new <see cref="STLDocument"/>.</summary>
        /// <remarks>This method expects a text-based STL document to be contained within the <paramref name="reader"/>.</remarks>
        /// <param name="reader">The reader which contains the text-based STL data.</param>
        /// <returns>An <see cref="STLDocument"/> representing the data contained in the stream or null if the stream is empty.</returns>
        public static STLDocument Read(StreamReader reader)
        {
            if (reader == null) throw new NullReferenceException(nameof(reader));

            const string regexSolid = @"solid\s+(?<Name>[^\r\n]+)?";
            string? header = reader.ReadLine();
            Match headerMatch;
            STLDocument stl;

            // Check the header.
            if (header == null || !(headerMatch = Regex.Match(header, regexSolid)).Success) throw new FormatException($"Invalid STL header, expected \"solid [name]\" but found: {header}");

            // Create the STL and extract the name (optional).
            stl = new STLDocument()
            {
                Name = headerMatch.Groups["Name"].Value
            };

            // Read each facet until the end of the stream.
            while (!reader.EndOfStream)
            {
                // Peek the next char to make sure it's a facet (e.g. not "endsolid").
                if (((char)reader.Peek()) == 'e') break;

                stl.Facets.Add(Facet.Read(reader));

            }

            return stl;
        }

        /// <summary>Reads the STL document contained within the <paramref name="stl"/> parameter into a new <see cref="STLDocument"/>.</summary>
        /// <param name="stl">A string which contains the STL data.</param>
        /// <returns>An <see cref="STLDocument"/> representing the data contained in the <paramref name="stl"/> parameter or null if the parameter is empty.</returns>
        public static STLDocument Read(string stl)
        {
            if (stl.IsNullOrEmpty()) return new STLDocument();

            using (var stream = new MemoryStream(Encoding.ASCII.GetBytes(stl)))
            {
                return Read(stream);
            }
        }

        /// <summary>Reads the STL document located at the <paramref name="path"/> into a new <see cref="STLDocument"/>.</summary>
        /// <param name="path">A full path to a file which contains the STL data.</param>
        /// <returns>An <see cref="STLDocument"/> representing the data contained in the file located at the <paramref name="path"/> specified or null if the parameter is empty.</returns>
        public static STLDocument Open(string path)
        {
            if (path.IsNullOrEmpty())
            {
                throw new ArgumentNullException("path");
            }

            using (var stream = File.OpenRead(path))
            {
                return Read(stream);
            }
        }

        /// <summary>Reads the STL document contained within the <paramref name="reader"/> into a new <see cref="STLDocument"/>.</summary>
        /// <remarks>This method will expects a binary-based <see cref="STLDocument"/> to be contained within the <paramref name="reader"/>.</remarks>
        /// <param name="reader">The reader which contains the binary-based STL data.</param>
        /// <returns>An <see cref="STLDocument"/> representing the data contained in the stream or null if the stream is empty.</returns>
        public static STLDocument Read(BinaryReader reader)
        {
            if (reader == null) throw new NullReferenceException(nameof(reader));

            byte[] buffer = new byte[80];
            STLDocument stl = new STLDocument();
            Facet currentFacet;

            // Read (and ignore) the header and number of triangles.
            buffer = reader.ReadBytes(80);
            reader.ReadBytes(4);

            // Read each facet until the end of the stream. Stop when the end of the stream is reached.
            while ((reader.BaseStream.Position != reader.BaseStream.Length) && (currentFacet = Facet.Read(reader)) != null)
            {
                stl.Facets.Add(currentFacet);
            }

            return stl;
        }

        /// <summary>Reads the <see cref="STLDocument"/> within the <paramref name="inStream"/> as text into the <paramref name="outStream"/>.</summary>
        /// <param name="inStream">The stream to read from.</param>
        /// <param name="outStream">The stream to read into.</param>
        /// <returns>The <see cref="STLDocument"/> that was copied.</returns>
        public static STLDocument CopyAsText(Stream inStream, Stream outStream)
        {
            STLDocument stl = Read(inStream);

            stl.WriteText(outStream);

            return stl;
        }

        /// <summary>Reads the <see cref="STLDocument"/> within the <paramref name="inStream"/> as binary into the <paramref name="outStream"/>.</summary>
        /// <param name="inStream">The stream to read from.</param>
        /// <param name="outStream">The stream to read into.</param>
        /// <returns>The <see cref="STLDocument"/> that was copied.</returns>
        public static STLDocument CopyAsBinary(Stream inStream, Stream outStream)
        {
            STLDocument stl = Read(inStream);

            stl.WriteBinary(outStream);

            return stl;
        }

        /// <summary>Returns the header representation of this <see cref="STLDocument"/>.</summary>
        public override string ToString()
        {
            return $"solid {Name}";
        }

        /// <see cref="Object.GetHashCode"/>
        public override int GetHashCode()
        {
            return this.ToString().GetHashCode();
        }

        /// <see cref="Equals(STLDocument)"/>
        public override bool Equals(object? other)
        {
            return Equals(other as STLDocument);
        }

        /// <summary>Determines whether or not this instance is the same as the <paramref name="other"/> instance.</summary>
        /// <param name="other">The <see cref="STLDocument"/> to which to compare.</param>
        /// <returns>True if this instance is equal to the <paramref name="other"/> instance.</returns>
        public bool Equals(STLDocument? other)
        {
            return
                other != null &&
                Facets.Count == other.Facets.Count &&
                Facets.All((i, o) => o.Equals(other.Facets[i]));
        }

        /// <summary>Iterates through the <see cref="Facets"/> collection.</summary>
        public IEnumerator<Facet> GetEnumerator()
        {
            return Facets.GetEnumerator();
        }

        /// <summary>Iterates through the <see cref="Facets"/> collection.</summary>
        System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator()
        {
            return GetEnumerator();
        }
    }
}
