using System;
using System.Collections;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Text;
using Swan.Reflection;

namespace Swan.Formatters {
  /// <summary>
  /// A CSV writer useful for exporting a set of objects.
  /// </summary>
  /// <example>
  /// The following code describes how to save a list of objects into a CSV file.
  /// <code>
  /// using System.Collections.Generic;
  /// using Swan.Formatters;
  ///  
  /// class Example
  /// {
  ///     class Person
  ///     {
  ///         public string Name { get; set; }
  ///         public int Age { get; set; }
  ///     }
  ///     
  ///     static void Main()
  ///     {
  ///         // create a list of people 
  ///         var people = new List&lt;Person&gt;
  ///         {
  ///             new Person { Name = "Artyom", Age = 20 },
  ///             new Person { Name = "Aloy", Age = 18 }
  ///         }
  ///         
  ///         // write items inside file.csv
  ///         CsvWriter.SaveRecords(people, "C:\\Users\\user\\Documents\\file.csv");
  ///         
  ///         // output
  ///         // | Name   | Age |
  ///         // | Artyom | 20  |
  ///         // | Aloy   | 18  |
  ///     }
  /// }
  /// </code>
  /// </example>
  public class CsvWriter : IDisposable {
    private static readonly PropertyTypeCache TypeCache = new PropertyTypeCache();

    private readonly Object _syncLock = new Object();
    private readonly Stream _outputStream;
    private readonly Encoding _encoding;
    private readonly Boolean _leaveStreamOpen;
    private Boolean _isDisposing;
    private UInt64 _mCount;

    #region Constructors

    /// <summary>
    /// Initializes a new instance of the <see cref="CsvWriter" /> class.
    /// </summary>
    /// <param name="outputStream">The output stream.</param>
    /// <param name="leaveOpen">if set to <c>true</c> [leave open].</param>
    /// <param name="encoding">The encoding.</param>
    public CsvWriter(Stream outputStream, Boolean leaveOpen, Encoding encoding) {
      this._outputStream = outputStream;
      this._encoding = encoding;
      this._leaveStreamOpen = leaveOpen;
    }

    /// <summary>
    /// Initializes a new instance of the <see cref="CsvWriter"/> class.
    /// It automatically closes the stream when disposing this writer.
    /// </summary>
    /// <param name="outputStream">The output stream.</param>
    /// <param name="encoding">The encoding.</param>
    public CsvWriter(Stream outputStream, Encoding encoding) : this(outputStream, false, encoding) {
      // placeholder
    }

    /// <summary>
    /// Initializes a new instance of the <see cref="CsvWriter"/> class.
    /// It uses the Windows 1252 encoding and automatically closes
    /// the stream upon disposing this writer.
    /// </summary>
    /// <param name="outputStream">The output stream.</param>
    public CsvWriter(Stream outputStream) : this(outputStream, false, Definitions.Windows1252Encoding) {
      // placeholder
    }

    /// <summary>
    /// Initializes a new instance of the <see cref="CsvWriter"/> class.
    /// It opens the file given file, automatically closes the stream upon 
    /// disposing of this writer, and uses the Windows 1252 encoding.
    /// </summary>
    /// <param name="filename">The filename.</param>
    public CsvWriter(String filename) : this(File.OpenWrite(filename), false, Definitions.Windows1252Encoding) {
      // placeholder
    }

    /// <summary>
    /// Initializes a new instance of the <see cref="CsvWriter"/> class.
    /// It opens the file given file, automatically closes the stream upon 
    /// disposing of this writer, and uses the given text encoding for output.
    /// </summary>
    /// <param name="filename">The filename.</param>
    /// <param name="encoding">The encoding.</param>
    public CsvWriter(String filename, Encoding encoding) : this(File.OpenWrite(filename), false, encoding) {
      // placeholder
    }

    #endregion

    #region Properties

    /// <summary>
    /// Gets or sets the field separator character.
    /// </summary>
    /// <value>
    /// The separator character.
    /// </value>
    public Char SeparatorCharacter { get; set; } = ',';

    /// <summary>
    /// Gets or sets the escape character to use to escape field values.
    /// </summary>
    /// <value>
    /// The escape character.
    /// </value>
    public Char EscapeCharacter { get; set; } = '"';

    /// <summary>
    /// Gets or sets the new line character sequence to use when writing a line.
    /// </summary>
    /// <value>
    /// The new line sequence.
    /// </value>
    public String NewLineSequence { get; set; } = Environment.NewLine;

    /// <summary>
    /// Defines a list of properties to ignore when outputting CSV lines.
    /// </summary>
    /// <value>
    /// The ignore property names.
    /// </value>
    public List<String> IgnorePropertyNames { get; } = new List<String>();

    /// <summary>
    /// Gets number of lines that have been written, including the headings line.
    /// </summary>
    /// <value>
    /// The count.
    /// </value>
    public UInt64 Count {
      get {
        lock(this._syncLock) {
          return this._mCount;
        }
      }
    }

    #endregion

    #region Helpers

    /// <summary>
    /// Saves the items to a stream.
    /// It uses the Windows 1252 text encoding for output.
    /// </summary>
    /// <typeparam name="T">The type of enumeration.</typeparam>
    /// <param name="items">The items.</param>
    /// <param name="stream">The stream.</param>
    /// <param name="truncateData"><c>true</c> if stream is truncated, default <c>false</c>.</param>
    /// <returns>Number of item saved.</returns>
    public static Int32 SaveRecords<T>(IEnumerable<T> items, Stream stream, Boolean truncateData = false) {
      // truncate the file if it had data
      if(truncateData && stream.Length > 0) {
        stream.SetLength(0);
      }

      using CsvWriter writer = new CsvWriter(stream);
      writer.WriteHeadings<T>();
      writer.WriteObjects(items);
      return (Int32)writer.Count;
    }

    /// <summary>
    /// Saves the items to a CSV file.
    /// If the file exits, it overwrites it. If it does not, it creates it.
    /// It uses the Windows 1252 text encoding for output.
    /// </summary>
    /// <typeparam name="T">The type of enumeration.</typeparam>
    /// <param name="items">The items.</param>
    /// <param name="filePath">The file path.</param>
    /// <returns>Number of item saved.</returns>
    public static Int32 SaveRecords<T>(IEnumerable<T> items, String filePath) => SaveRecords(items, File.OpenWrite(filePath), true);

    #endregion

    #region Generic, main Write Line Method

    /// <summary>
    /// Writes a line of CSV text. Items are converted to strings.
    /// If items are found to be null, empty strings are written out.
    /// If items are not string, the ToStringInvariant() method is called on them.
    /// </summary>
    /// <param name="items">The items.</param>
    public void WriteLine(params Object[] items) => this.WriteLine(items.Select(x => x == null ? String.Empty : x.ToStringInvariant()));

    /// <summary>
    /// Writes a line of CSV text. Items are converted to strings.
    /// If items are found to be null, empty strings are written out.
    /// If items are not string, the ToStringInvariant() method is called on them.
    /// </summary>
    /// <param name="items">The items.</param>
    public void WriteLine(IEnumerable<Object> items) => this.WriteLine(items.Select(x => x == null ? String.Empty : x.ToStringInvariant()));

    /// <summary>
    /// Writes a line of CSV text.
    /// If items are found to be null, empty strings are written out.
    /// </summary>
    /// <param name="items">The items.</param>
    public void WriteLine(params String[] items) => this.WriteLine((IEnumerable<String>)items);

    /// <summary>
    /// Writes a line of CSV text.
    /// If items are found to be null, empty strings are written out.
    /// </summary>
    /// <param name="items">The items.</param>
    public void WriteLine(IEnumerable<String> items) {
      lock(this._syncLock) {
        Int32 length = items.Count();
        Byte[] separatorBytes = this._encoding.GetBytes(new[] { this.SeparatorCharacter });
        Byte[] endOfLineBytes = this._encoding.GetBytes(this.NewLineSequence);

        // Declare state variables here to avoid recreation, allocation and
        // reassignment in every loop
        Boolean needsEnclosing;
        String textValue;
        Byte[] output;

        for(Int32 i = 0; i < length; i++) {
          textValue = items.ElementAt(i);

          // Determine if we need the string to be enclosed 
          // (it either contains an escape, new line, or separator char)
          needsEnclosing = textValue.IndexOf(this.SeparatorCharacter) >= 0 || textValue.IndexOf(this.EscapeCharacter) >= 0 || textValue.IndexOf('\r') >= 0 || textValue.IndexOf('\n') >= 0;

          // Escape the escape characters by repeating them twice for every instance
          textValue = textValue.Replace($"{this.EscapeCharacter}", $"{this.EscapeCharacter}{this.EscapeCharacter}");

          // Enclose the text value if we need to
          if(needsEnclosing) {
            textValue = String.Format($"{this.EscapeCharacter}{textValue}{this.EscapeCharacter}", textValue);
          }

          // Get the bytes to write to the stream and write them
          output = this._encoding.GetBytes(textValue);
          this._outputStream.Write(output, 0, output.Length);

          // only write a separator if we are moving in between values.
          // the last value should not be written.
          if(i < length - 1) {
            this._outputStream.Write(separatorBytes, 0, separatorBytes.Length);
          }
        }

        // output the newline sequence
        this._outputStream.Write(endOfLineBytes, 0, endOfLineBytes.Length);
        this._mCount += 1;
      }
    }

    #endregion

    #region Write Object Method

    /// <summary>
    /// Writes a row of CSV text. It handles the special cases where the object is
    /// a dynamic object or and array. It also handles non-collection objects fine.
    /// If you do not like the way the output is handled, you can simply write an extension
    /// method of this class and use the WriteLine method instead.
    /// </summary>
    /// <param name="item">The item.</param>
    /// <exception cref="System.ArgumentNullException">item.</exception>
    public void WriteObject(Object item) {
      if(item == null) {
        throw new ArgumentNullException(nameof(item));
      }

      lock(this._syncLock) {
        switch(item) {
          case IDictionary typedItem:
            this.WriteLine(this.GetFilteredDictionary(typedItem));
            return;
          case ICollection typedItem:
            this.WriteLine(typedItem.Cast<Object>());
            return;
          default:
            this.WriteLine(this.GetFilteredTypeProperties(item.GetType()).Select(x => x.ToFormattedString(item)));
            break;
        }
      }
    }

    /// <summary>
    /// Writes a row of CSV text. It handles the special cases where the object is
    /// a dynamic object or and array. It also handles non-collection objects fine.
    /// If you do not like the way the output is handled, you can simply write an extension
    /// method of this class and use the WriteLine method instead.
    /// </summary>
    /// <typeparam name="T">The type of object to write.</typeparam>
    /// <param name="item">The item.</param>
    public void WriteObject<T>(T item) => this.WriteObject(item as Object);

    /// <summary>
    /// Writes a set of items, one per line and atomically by repeatedly calling the
    /// WriteObject method. For more info check out the description of the WriteObject
    /// method.
    /// </summary>
    /// <typeparam name="T">The type of object to write.</typeparam>
    /// <param name="items">The items.</param>
    public void WriteObjects<T>(IEnumerable<T> items) {
      lock(this._syncLock) {
        foreach(T item in items) {
          this.WriteObject(item);
        }
      }
    }

    #endregion

    #region Write Headings Methods

    /// <summary>
    /// Writes the headings.
    /// </summary>
    /// <param name="type">The type of object to extract headings.</param>
    /// <exception cref="System.ArgumentNullException">type.</exception>
    public void WriteHeadings(Type type) {
      if(type == null) {
        throw new ArgumentNullException(nameof(type));
      }

      IEnumerable<Object> properties = this.GetFilteredTypeProperties(type).Select(p => p.Name).Cast<Object>();
      this.WriteLine(properties);
    }

    /// <summary>
    /// Writes the headings.
    /// </summary>
    /// <typeparam name="T">The type of object to extract headings.</typeparam>
    public void WriteHeadings<T>() => this.WriteHeadings(typeof(T));

    /// <summary>
    /// Writes the headings.
    /// </summary>
    /// <param name="dictionary">The dictionary to extract headings.</param>
    /// <exception cref="System.ArgumentNullException">dictionary.</exception>
    public void WriteHeadings(IDictionary dictionary) {
      if(dictionary == null) {
        throw new ArgumentNullException(nameof(dictionary));
      }

      this.WriteLine(this.GetFilteredDictionary(dictionary, true));
    }

    /// <summary>
    /// Writes the headings.
    /// </summary>
    /// <param name="obj">The object to extract headings.</param>
    /// <exception cref="ArgumentNullException">obj.</exception>
    public void WriteHeadings(Object obj) {
      if(obj == null) {
        throw new ArgumentNullException(nameof(obj));
      }

      this.WriteHeadings(obj.GetType());
    }

    #endregion

    #region IDisposable Support

    /// <inheritdoc />
    public void Dispose() => this.Dispose(true);

    /// <summary>
    /// Releases unmanaged and - optionally - managed resources.
    /// </summary>
    /// <param name="disposeAlsoManaged"><c>true</c> to release both managed and unmanaged resources; <c>false</c> to release only unmanaged resources.</param>
    protected virtual void Dispose(Boolean disposeAlsoManaged) {
      if(this._isDisposing) {
        return;
      }

      if(disposeAlsoManaged) {
        if(this._leaveStreamOpen == false) {
          this._outputStream.Dispose();
        }
      }

      this._isDisposing = true;
    }

    #endregion

    #region Support Methods

    private IEnumerable<String> GetFilteredDictionary(IDictionary dictionary, Boolean filterKeys = false) => dictionary.Keys.Cast<Object>()
            .Select(key => key == null ? String.Empty : key.ToStringInvariant())
            .Where(stringKey => !this.IgnorePropertyNames.Contains(stringKey))
            .Select(stringKey => filterKeys ? stringKey : dictionary[stringKey] == null ? String.Empty : dictionary[stringKey].ToStringInvariant());

    private IEnumerable<PropertyInfo> GetFilteredTypeProperties(Type type) => TypeCache.Retrieve(type, t => t.GetProperties(BindingFlags.Public | BindingFlags.Instance).Where(p => p.CanRead)).Where(p => !this.IgnorePropertyNames.Contains(p.Name));

    #endregion

  }
}