// Copyright (c) 2013-2018 Cemalettin Dervis, MIT License.
// https://github.com/cemdervis/SharpConfig

using System;
using System.Collections;
using System.Collections.Generic;
using System.Reflection;

namespace SharpConfig
{
  /// <summary>
  /// Represents a group of <see cref="Setting"/> objects.
  /// </summary>
  public sealed class Section : ConfigurationElement, IEnumerable<Setting>
  {
    private readonly List<Setting> mSettings;

    /// <summary>
    /// Initializes a new instance of the <see cref="Section"/> class.
    /// </summary>
    ///
    /// <param name="name">The name of the section.</param>
    public Section(string name)
        : base(name)
    {
      mSettings = new List<Setting>();
    }

    /// <summary>
    /// Creates a new instance of the <see cref="Section"/> class that is
    /// based on an existing object.
    /// Important: the section is built only from the public getter properties
    /// and fields of its type.
    /// When this method is called, all of those properties will be called
    /// and fields accessed once to obtain their values.
    /// Properties and fields that are marked with the <see cref="IgnoreAttribute"/> attribute
    /// or are of a type that is marked with that attribute, are ignored.
    /// </summary>
    /// <param name="name">The name of the section.</param>
    /// <param name="obj"></param>
    /// <returns>The newly created section.</returns>
    /// 
    /// <exception cref="ArgumentException">When <paramref name="name"/> is null or empty.</exception>
    /// <exception cref="ArgumentNullException">When <paramref name="obj"/> is null.</exception>
    public static Section FromObject(string name, object obj)
    {
      if (string.IsNullOrEmpty(name))
        throw new ArgumentException("The section name must not be null or empty.", "name");

      if (obj == null)
        throw new ArgumentNullException("obj");

      var section = new Section(name);
      var type = obj.GetType();

      foreach (var prop in type.GetProperties(BindingFlags.Instance | BindingFlags.Public))
      {
        if (!prop.CanRead || ShouldIgnoreMappingFor(prop))
        {
          // Skip this property, as it can't be read from.
          continue;
        }

        var setting = new Setting(prop.Name, prop.GetValue(obj, null));
        section.mSettings.Add(setting);
      }

      // Repeat for each public field.
      foreach (var field in type.GetFields(BindingFlags.Instance | BindingFlags.Public))
      {
        if (ShouldIgnoreMappingFor(field))
        {
          // Skip this field.
          continue;
        }

        var setting = new Setting(field.Name, field.GetValue(obj));
        section.mSettings.Add(setting);
      }

      return section;
    }

    /// <summary>
    /// Creates an object of a specific type, and maps the settings
    /// in this section to the public properties and writable fields of the object.
    /// Properties and fields that are marked with the <see cref="IgnoreAttribute"/> attribute
    /// or are of a type that is marked with that attribute, are ignored.
    /// </summary>
    /// 
    /// <typeparam name="T">
    /// The type of object to create.
    /// Note: the type must be default-constructible, meaning it has a public default constructor.
    /// </typeparam>
    /// 
    /// <returns>The created object.</returns>
    /// 
    /// <remarks>
    /// The specified type must have a public default constructor
    /// in order to be created.
    /// </remarks>
    public T ToObject<T>() where T : new()
    {
      var obj = Activator.CreateInstance<T>();
      SetValuesTo(obj);
      return obj;
    }

    /// <summary>
    /// Creates an object of a specific type, and maps the settings
    /// in this section to the public properties and writable fields of the object.
    /// Properties and fields that are marked with the <see cref="IgnoreAttribute"/> attribute
    /// or are of a type that is marked with that attribute, are ignored.
    /// </summary>
    /// 
    /// <param name="type">
    /// The type of object to create.
    /// Note: the type must be default-constructible, meaning it has a public default constructor.
    /// </param>
    /// 
    /// <returns>The created object.</returns>
    /// 
    /// <remarks>
    /// The specified type must have a public default constructor
    /// in order to be created.
    /// </remarks>
    /// 
    /// <exception cref="ArgumentNullException">When <paramref name="type"/> is null.</exception>
    public object ToObject(Type type)
    {
      if (type == null)
        throw new ArgumentNullException(type.Name);

      var obj = Activator.CreateInstance(type);
      SetValuesTo(obj);
      return obj;
    }

    /// <summary>
    /// Assigns the values of an object's public properties and fields to the corresponding
    /// <b>already existing</b> settings in this section.
    /// Properties and fields that are marked with the <see cref="IgnoreAttribute"/> attribute
    /// or are of a type that is marked with that attribute, are ignored.
    /// </summary>
    /// 
    /// <param name="obj">The object from which the values are obtained.</param>
    /// 
    /// <exception cref="ArgumentNullException">When <paramref name="obj"/> is null.</exception>
    public void GetValuesFrom(object obj)
    {
      if (obj == null)
        throw new ArgumentNullException("obj");

      var type = obj.GetType();

      // Scan the type's properties.
      foreach (var prop in type.GetProperties(BindingFlags.Instance | BindingFlags.Public))
      {
        if (!prop.CanRead)
          continue;

        SetSettingValueFromMemberInfo(prop, obj);
      }

      // Scan the type's fields.
      foreach (var field in type.GetFields(BindingFlags.Instance | BindingFlags.Public))
      {
        SetSettingValueFromMemberInfo(field, obj);
      }
    }

    private void SetSettingValueFromMemberInfo(MemberInfo info, object instance)
    {
      if (ShouldIgnoreMappingFor(info))
        return;

      var setting = FindSetting(info.Name);
      if (setting != null)
      {
        object value = null;

        if (info is FieldInfo)
        {
          value = ((FieldInfo)info).GetValue(instance);
        }
        else if (info is PropertyInfo)
        {
          value = ((PropertyInfo)info).GetValue(instance, null);
        }

        setting.SetValue(value);
      }
    }

    /// <summary>
    /// Assigns the values of this section to an object's public properties and fields.
    /// Properties and fields that are marked with the <see cref="IgnoreAttribute"/> attribute
    /// or are of a type that is marked with that attribute, are ignored.
    /// </summary>
    /// 
    /// <param name="obj">The object that is modified based on the section.</param>
    /// 
    /// <exception cref="ArgumentNullException">When <paramref name="obj"/> is null.</exception>
    public void SetValuesTo(object obj)
    {
      if (obj == null)
        throw new ArgumentNullException("obj");

      var type = obj.GetType();

      // Scan the type's properties.
      foreach (var prop in type.GetProperties(BindingFlags.Instance | BindingFlags.Public))
      {
        if (!prop.CanWrite || ShouldIgnoreMappingFor(prop))
          continue;

        var setting = FindSetting(prop.Name);
        if (setting == null)
          continue;

        object value = prop.PropertyType.IsArray ?
          setting.GetValueArray(prop.PropertyType.GetElementType()) :
          setting.GetValue(prop.PropertyType);

        if (prop.PropertyType.IsArray)
        {
          var settingArray = value as Array;
          var propArray = prop.GetValue(obj, null) as Array;
          if (settingArray != null && (propArray == null || propArray.Length != settingArray.Length))
          {
            // (Re)create the property's array.
            propArray = Array.CreateInstance(prop.PropertyType.GetElementType(), length: settingArray.Length);
          }

          for (int i = 0; i < settingArray.Length; i++)
          {
            propArray?.SetValue(settingArray.GetValue(i), i);
          }

          prop.SetValue(obj, propArray, null);
        }
        else
        {
          prop.SetValue(obj, value, null);
        }
      }

      // Scan the type's fields.
      foreach (var field in type.GetFields(BindingFlags.Instance | BindingFlags.Public))
      {
        // Skip readonly fields.
        if (field.IsInitOnly || ShouldIgnoreMappingFor(field))
          continue;

        var setting = FindSetting(field.Name);
        if (setting == null)
          continue;

        object value = field.FieldType.IsArray ?
          setting.GetValueArray(field.FieldType.GetElementType()) :
          setting.GetValue(field.FieldType);

        if (field.FieldType.IsArray)
        {
          var settingArray = value as Array;
          var fieldArray = field.GetValue(obj) as Array;
          if (settingArray != null && (fieldArray == null || fieldArray.Length != settingArray.Length))
          {
            // (Re)create the field's array.
            fieldArray = Array.CreateInstance(field.FieldType.GetElementType(), settingArray.Length);
          }

          for (int i = 0; i < settingArray.Length; i++)
            fieldArray.SetValue(settingArray.GetValue(i), i);

          field.SetValue(obj, fieldArray);
        }
        else
        {
          field.SetValue(obj, value);
        }
      }
    }

    // Determines whether a member should be ignored.
    private static bool ShouldIgnoreMappingFor(MemberInfo member)
    {
      if (member.GetCustomAttributes(typeof(IgnoreAttribute), false).Length > 0)
      {
        return true;
      }
      else
      {
        var prop = member as PropertyInfo;
        if (prop != null)
          return prop.PropertyType.GetCustomAttributes(typeof(IgnoreAttribute), false).Length > 0;

        var field = member as FieldInfo;
        if (field != null)
          return field.FieldType.GetCustomAttributes(typeof(IgnoreAttribute), false).Length > 0;
      }

      return false;
    }

    /// <summary>
    /// Gets an enumerator that iterates through the section.
    /// </summary>
    public IEnumerator<Setting> GetEnumerator()
    {
      return mSettings.GetEnumerator();
    }

    /// <summary>
    /// Gets an enumerator that iterates through the section.
    /// </summary>
    IEnumerator IEnumerable.GetEnumerator()
    {
      return GetEnumerator();
    }

    /// <summary>
    /// Adds a setting to the section.
    /// </summary>
    /// <param name="setting">The setting to add.</param>
    /// 
    /// <exception cref="ArgumentNullException">When <paramref name="setting"/> is null.</exception>
    /// <exception cref="ArgumentException">When the specified setting already exists in the section.</exception>
    public void Add(Setting setting)
    {
      if (setting == null)
        throw new ArgumentNullException("setting");

      if (Contains(setting))
        throw new ArgumentException("The specified setting already exists in the section.");

      mSettings.Add(setting);
    }

    /// <summary>
    /// Adds a setting with a specific name and empty value to the section.
    /// </summary>
    /// <param name="settingName">The name of the setting to add.</param>
    /// <returns>The added setting.</returns>
    /// <exception cref="ArgumentNullException">When <paramref name="settingName"/> is null or empty.</exception>
    public Setting Add(string settingName) => Add(settingName, string.Empty);

    /// <summary>
    /// Adds a setting with a specific name and value to the section.
    /// </summary>
    /// <param name="settingName">The name of the setting to add.</param>
    /// <param name="settingValue">The initial value of the setting to add.</param>
    /// <returns>The added setting.</returns>
    /// <exception cref="ArgumentNullException">When <paramref name="settingName"/> is null or empty.</exception>
    public Setting Add(string settingName, object settingValue)
    {
      var setting = new Setting(settingName, settingValue);
      Add(setting);
      return setting;
    }

    /// <summary>
    /// Removes a setting from the section by its name.
    /// If there are multiple settings with the same name, only the first setting is removed.
    /// To remove all settings that have the name name, use the RemoveAllNamed() method instead.
    /// </summary>
    /// <param name="settingName">The case-sensitive name of the setting to remove.</param>
    /// <returns>True if a setting with the specified name was removed; false otherwise.</returns>
    /// 
    /// <exception cref="ArgumentNullException">When <paramref name="settingName"/> is null or empty.</exception>
    public bool Remove(string settingName)
    {
      if (string.IsNullOrEmpty(settingName))
        throw new ArgumentNullException("settingName");

      return Remove(FindSetting(settingName));
    }

    /// <summary>
    /// Removes a setting from the section.
    /// </summary>
    /// <param name="setting">The setting to remove.</param>
    /// <returns>True if the setting was removed; false otherwise.</returns>
    public bool Remove(Setting setting)
    {
      return mSettings.Remove(setting);
    }

    /// <summary>
    /// Removes all settings that have a specific name.
    /// </summary>
    /// <param name="settingName">The case-sensitive name of the settings to remove.</param>
    /// 
    /// <exception cref="ArgumentNullException">When <paramref name="settingName"/> is null or empty.</exception>
    public void RemoveAllNamed(string settingName)
    {
      if (string.IsNullOrEmpty(settingName))
        throw new ArgumentNullException("settingName");

      while (Remove(settingName)) ;
    }

    /// <summary>
    /// Clears the section of all settings.
    /// </summary>
    public void Clear()
    {
      mSettings.Clear();
    }

    /// <summary>
    /// Determines whether a specified setting is contained in the section.
    /// </summary>
    /// <param name="setting">The setting to check for containment.</param>
    /// <returns>True if the setting is contained in the section; false otherwise.</returns>
    public bool Contains(Setting setting)
    {
      return mSettings.Contains(setting);
    }

    /// <summary>
    /// Determines whether a specifically named setting is contained in the section.
    /// </summary>
    /// <param name="settingName">The case-sensitive name of the setting.</param>
    /// <returns>True if the setting is contained in the section; false otherwise.</returns>
    ///
    /// <exception cref="ArgumentNullException">When <paramref name="settingName"/> is null or empty.</exception>
    public bool Contains(string settingName)
    {
      if (string.IsNullOrEmpty(settingName))
        throw new ArgumentNullException("settingName");

      return FindSetting(settingName) != null;
    }

    /// <summary>
    /// Gets the number of settings that are in the section.
    /// </summary>
    public int SettingCount => mSettings.Count;

    /// <summary>
    /// Gets or sets a setting by index.
    /// </summary>
    /// <param name="index">The index of the setting in the section.</param>
    /// 
    /// <returns>
    /// The setting at the specified index.
    /// Note: no setting is created when using this accessor.
    /// </returns>
    /// 
    /// <exception cref="ArgumentOutOfRangeException">When <paramref name="index"/> is out of range.</exception>
    public Setting this[int index]
    {
      get
      {
        if (index < 0 || index >= mSettings.Count)
          throw new ArgumentOutOfRangeException("index");

        return mSettings[index];
      }
    }

    /// <summary>
    /// Gets or sets a setting by its name.
    /// If there are multiple settings with the same name, the first setting is returned.
    /// If you want to obtain all settings that have the same name, use the GetSettingsNamed() method instead.
    /// </summary>
    ///
    /// <param name="name">The case-sensitive name of the setting.</param>
    ///
    /// <returns>
    /// The setting if found, otherwise a new setting with
    /// the specified name is created, added to the section and returned.
    /// </returns>
    public Setting this[string name]
    {
      get
      {
        var setting = FindSetting(name);
        if (setting == null)
        {
          setting = new Setting(name);
          mSettings.Add(setting);
        }

        return setting;
      }
    }

    /// <summary>
    /// Gets all settings that have a specific name.
    /// </summary>
    /// <param name="name">The case-sensitive name of the settings.</param>
    /// <returns>
    /// The found settings.
    /// </returns>
    public IEnumerable<Setting> GetSettingsNamed(string name)
    {
      var settings = new List<Setting>();

      foreach (var setting in mSettings)
      {
        if (string.Equals(setting.Name, name, StringComparison.OrdinalIgnoreCase))
          settings.Add(setting);
      }

      return settings;
    }

    // Finds a setting by its name.
    private Setting FindSetting(string name)
    {
      foreach (var setting in mSettings)
      {
        if (string.Equals(setting.Name, name, StringComparison.OrdinalIgnoreCase))
          return setting;
      }

      return null;
    }

    /// <summary>
    /// Gets the element's expression as a string.
    /// An example for a section would be "[Section]".
    /// </summary>
    /// <returns>The element's expression as a string.</returns>
    protected override string GetStringExpression()
    {
      return $"[{Name}]";
    }
  }
}
