/*
 * Permission to use, copy, modify, and/or distribute this software for any
 * purpose with or without fee is herby granted.
 *
 * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
 * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
 * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
 * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
 * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
 * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
 * OR IN CONNECTION WITH THE USE OR PEFORMANCE OF THIS SOFTWARE.
 */


/**
 * An incomplete single-file INI parser for D.
 *
 * The API should be similar to python's configparse module.  Internally it
 * uses the standard D associative array.
 *
 * Example:
 * ---
 * import configparser;
 *
 * auto config = new ConfigParser();
 * // no sections initially
 * assert(config.sections.length == 0);
 * // Section names ("Program Settings") are case-sensitive
 * conf.addSection("Storage Paths");
 * // Option names ("CONFIG_PATH") are case-insensitive
 * // (internally, they are all converted to lower-case)
 * conf.set("Program Settings", "CONFIG_PATH", "/home/user/.local/config");
 * ---
 *
 * Authors: nemophila
 * Date: 2023-03-19
 * Homepage: https://osdn.net/users/nemophila/pf/mlib
 * License: 0BSD
 * Version: 0.4
 *
 * History:
 *      0.4 Add .write()
 *      0.3 Fix option values not always being treated as lowercase.
 *      0.2 Add .getBool()
 *      0.1 Initial release
 */
module mlib.configparser;

private
{
    import std.conv : ConvException;
    import std.stdio : File;
}

public class DuplicateSectionException : Exception
{
    private string m_section;

    this(string section)
    {
        string msg = "Section " ~ section ~ " already exists.";
        m_section = section;
        super(msg);
    }

    string section()
    {
        return m_section;
    }
}

///
/// An exception that is thrown by a strict parser which indicates
/// that an option appears twice within any one section.
///
public class DuplicateOptionException : Exception
{
    private string m_option;
    private string m_section;

    this(string option, string section)
    {
	string msg = "Option " ~ option ~ " in section " ~ section ~
	    " already exists.";
	m_option = option;
	m_section = section;
	super(msg);
    }

    string option()
    {
	return m_option;
    }

    string section()
    {
	return m_section;
    }
}

public class NoSectionException : Exception
{
    private string m_section;

    this(string section)
    {
        string msg = "Section '" ~ section ~ "' does not exist.";
        m_section = section;
        super(msg);
    }

    string section()
    {
        return m_section;
    }
}

public class NoOptionException : Exception
{
    private string m_section;
    private string m_option;

    this(string section, string option)
    {
        string msg = "Section '" ~ section ~ "' does not have option '" ~
            option ~ "'.";
        m_section = section;
        m_option = option;
        super(msg);
    }

    string section() { return m_section; }
    string option() { return m_option; }
}

/**
 * The main configuration parser.
 */
public class ConfigParser
{
    private char[] m_delimiters;
    private char[] m_commentPrefixes;
    private bool m_strict;

    /** current section for parsing */
    private string m_currentSection;
    private string[string][string] m_sections;

    /**
     * Creates a new instance of ConfigParser.
     */
    this(char[] delimiters = ['=', ':'],
	 char[] commentPrefixes = ['#', ';'],
	 bool strict = true)
    {
        m_delimiters = delimiters;
        m_commentPrefixes = commentPrefixes;
	m_strict = strict;
    }

    /**
     * Return an array containing the available sections.
     */
    string[] sections()
    {
        return m_sections.keys();
    }

    ///
    unittest
    {
        auto conf = new ConfigParser();

        assert(0 == conf.sections().length);

        conf.addSection("Section");

        assert(1 == conf.sections().length);
    }

    /**
     * Add a section named `section` to the instance.
     *
     * Throws:
     *   - DuplicateSectionError if a section by the given name already
     * exists.
     */
    void addSection(string section)
    {
        if (section in m_sections)
            throw new DuplicateSectionException(section);
        m_sections[section] = null;
    }

    ///
    unittest
    {
        import std.exception : assertNotThrown, assertThrown;

        auto conf = new ConfigParser();

        /* doesn't yet exist */
        assertNotThrown!DuplicateSectionException(conf.addSection("sample"));
        /* already exists */
        assertThrown!DuplicateSectionException(conf.addSection("sample"));
    }

    /**
     * Indicates whether the named `section` is present in the configuration.
     *
     * Params:
     *   section = The section to check for in the configuration.
     *
     * Returns: `true` if the section exists, `false` otherwise.
     */
    bool hasSection(string section)
    {
        auto exists = (section in m_sections);
        return (exists !is null);
    }

    ///
    unittest
    {
        auto conf = new ConfigParser();
        conf.addSection("nExt");
        assert(true == conf.hasSection("nExt"), "Close the world.");
        assert(false == conf.hasSection("world"), "Open the nExt.");
    }

    string[] options(string section)
    {
        if (false == this.hasSection(section))
            throw new NoSectionException(section);
        return m_sections[section].keys();
    }

    ///
    unittest
    {
        import std.exception : assertNotThrown, assertThrown;

        auto conf = new ConfigParser();

        conf.addSection("Settings");

        assertNotThrown!NoSectionException(conf.options("Settings"));
        assertThrown!NoSectionException(conf.options("void"));

        string[] options = conf.options("Settings");
        assert(0 == options.length, "More keys than we need");
    }

    bool hasOption(string section, string option)
    {
        import std.string : toLower;

        if (false == this.hasSection(section))
            return false;

        scope lowercaseOption = toLower(option);
        auto exists = (lowercaseOption in m_sections[section]);
        return (exists !is null);
    }
    /*
    string[] read(string[] filenames)
    {
        return null;
    }*/

    void read(string filename)
    {
        File file = File(filename, "r");
        scope(exit) { file.close(); }
        read(file, false);
    }

    ///
    unittest
    {
        import std.file : remove;
        import std.stdio : File;

        auto configFile = File("test.conf", "w+");
        configFile.writeln("[Section 1]");
        configFile.writeln("key=value");
        configFile.writeln("\n[Section 2]");
        configFile.writeln("key2 = value");
        configFile.close();

        auto conf = new ConfigParser();
        conf.read("test.conf");

        assert(2 == conf.sections.length, "Incorrect Sections length");
        assert(true == conf.hasSection("Section 1"),
               "Config file doesn't have Section 1");
        assert(true == conf.hasOption("Section 1", "key"),
               "Config file doesn't have 'key' in 'Section 1'");

        remove("test.conf");
    }

    /**
     * Parse a config file.
     *
     * Params:
     *   file = Reference to the file from which to read.
     *   close = Close the file when finished parsing.
     */
    void read(ref File file, bool close = true)
    {
        import std.array : array;
        import std.algorithm.searching : canFind;
        import std.string : strip;

        scope(exit) { if (close) file.close(); }

        string[] lines = file.byLineCopy.array;

        for (auto i = 0; i < lines.length; i++) {
            string line = lines[i].strip();

            if (line == "")
                continue;

            if ('[' == lines[i][0]) {
                parseSectionHeader(lines[i]);
            } else if (false == canFind(m_commentPrefixes, lines[i][0])) {
                parseLine(lines[i]);
            }
            /* ignore comments */
        }
    }

  /*void readString(string str)
    {
    }*/

    /**
     * Get an `option` value for the named `section`.
     *
     * Params:
     *   section = The section to look for the given `option`.
     *   option = The option to return the value of
     *   fallback = Fallback value if the `option` is not found. Can be null.
     *
     * Returns:
     *   - The value for `option` if it is found.
     *   - `null` if the `option` is not found and `fallback` is not provided.
     *   - `fallback` if the `option` is not found and `fallback` is provided.
     *
     * Throws:
     *   - NoSectionException if the `section` does not exist and no fallback is provided.
     *   - NoOptionException if the `option` does not exist and no fallback is provided.
     */
    string get(string section, string option)
    {
        import std.string : toLower;

        scope lowercaseOption = toLower(option);

        if (false == this.hasSection(section))
            throw new NoSectionException(section);

        if (false == this.hasOption(section, lowercaseOption))
            throw new NoOptionException(section, lowercaseOption);

        return m_sections[section][lowercaseOption];
    }

    ///
    unittest
    {
        import std.exception : assertThrown;

        auto conf = new ConfigParser();
        conf.addSection("Section");
        conf.set("Section", "option", "value");

        assert(conf.get("Section", "option") == "value");
        assertThrown!NoSectionException(conf.get("section", "option"));
        assertThrown!NoOptionException(conf.get("Section", "void"));
    }

    /// Ditto
    string get(string section, string option, string fallback)
    {
        string res = fallback;

        try {
            res = get(section, option);
        } catch (NoSectionException e) {
            return res;
        } catch (NoOptionException e) {
            return res;
        }

        return res;
    }

    ///
    unittest
    {
        import std.exception : assertThrown;

        auto conf = new ConfigParser();
        conf.addSection("Section");
        conf.set("Section", "option", "value");

        assert("value" == conf.get("Section", "option"));
        assert("fallback" == conf.get("section", "option", "fallback"));
        assert("fallback" == conf.get("Section", "void", "fallback"));

        /* can use null for fallback */
        assert(null == conf.get("section", "option", null));
        assert(null == conf.get("Section", "void", null));
    }

    /**
     * A convenience method which casts the value of `option` in `section`
     * to an integer.
     *
     * Params:
     *   section = The section to look for the given `option`.
     *   option = The option to return the value for.
     *   fallback = The fallback value to use if `option` isn't found.
     *
     * Returns:
     *
     *
     * Throws:
     *   - NoSectionFoundException if `section` doesn't exist.
     *   - NoOptionFoundException if the `section` doesn't contain `option`.
     *   - ConvException if it failed to parse the value to an int.
     *   - ConvOverflowException if the value would overflow an int.
     *
     * See_Also: get()
     */
    int getInt(string section, string option)
    {
        import std.conv : parse;

        string res;

        res = get(section, option);

        return parse!int(res);
    }

    /// Ditto
    int getInt(string section, string option, int fallback)
    {
        int res = fallback;

        try {
            res = getInt(section, option);
        } catch (Exception e) {
            return res;
        }

        return res;
    }

    /*
    double getDouble(string section, string option)
    {
    }

    double getDouble(string section, string option, double fallback)
    {
    }

    float getFloat(string section, string option)
    {
    }

    float getFloat(string section, string option, float fallback)
    {
    }*/

    /**
     * A convenience method which coerces the $(I option) in the
     * specified $(I section) to a boolean value.
     *
     * Note that the accepted values for the option are "1", "yes",
     * "true", and "on", which cause this method to return `true`, and
     * "0", "no", "false", and "off", which cause it to return `false`.
     *
     * These string values are checked in a case-insensitive manner.
     *
     * Params:
     *   section = The section to look for the given option.
     *   option = The option to return the value for.
     *   fallback = The fallback value to use if the option was not found.
     *
     * Throws:
     *   - NoSectionFoundException if `section` doesn't exist.
     *   - NoOptionFoundException if the `section` doesn't contain `option`.
     *   - ConvException if any other value was found.
     */
    bool getBool(string section, string option)
    {
        import std.string : toLower;

        string value = get(section, option);

        switch (value.toLower)
        {
        case "1":
        case "yes":
        case "true":
        case "on":
            return true;
        case "0":
        case "no":
        case "false":
        case "off":
            return false;
        default:
            throw new ConvException("No valid boolean value found");
        }
    }

    /// Ditto
    bool getBool(string section, string option, bool fallback)
    {
        try {
            return getBool(section, option);
        } catch (Exception e) {
            return fallback;
        }
    }

    /*
    string[string] items(string section)
    {
    }*/

    /**
     * Remove the specified `option` from the specified `section`.
     *
     * Params:
     *   section = The section to remove from.
     *   option = The option to remove from section.
     *
     * Retruns:
     *   `true` if option existed, false otherwise.
     *
     * Throws:
     *  - NoSectionException if the specified section doesn't exist.
     */
    bool removeOption(string section, string option)
    {
        if ((section in m_sections) is null) {
            throw new NoSectionException(section);
        }

        if (option in m_sections[section]) {
            m_sections[section].remove(option);
            return true;
        }

        return false;
    }

    ///
    unittest
    {
        import std.exception : assertThrown;

        auto conf = new ConfigParser();
        conf.addSection("Default");
        conf.set("Default", "exists", "true");

        assertThrown!NoSectionException(conf.removeOption("void", "false"));
        assert(false == conf.removeOption("Default", "void"));
        assert(true == conf.removeOption("Default", "exists"));
    }

    /**
     * Remove the specified `section` from the config.
     *
     * Params:
     *   section = The section to remove.
     *
     * Returns:
     *   `true` if the section existed, `false` otherwise.
     */
    bool removeSection(string section)
    {
        if (section in m_sections) {
            m_sections.remove(section);
            return true;
        }
        return false;
    }

    ///
    unittest
    {
        auto conf = new ConfigParser();
        conf.addSection("Exists");
        assert(false == conf.removeSection("DoesNotExist"));
        assert(true == conf.removeSection("Exists"));
    }

    void set(string section, string option, string value)
    {
        import std.string : toLower;

        if (false == this.hasSection(section))
            throw new NoSectionException(section);

        scope lowercaseOption = toLower(option);
        m_sections[section][lowercaseOption] = value;
    }

    ///
    unittest
    {
        import std.exception : assertThrown;

        auto conf = new ConfigParser();

        assertThrown!NoSectionException(conf.set("Section", "option",
            "value"));

        conf.addSection("Section");
        conf.set("Section", "option", "value");
        assert(conf.get("Section", "option") == "value");
    }

    ///
    /// Write a representation of the configuration to the
    /// provided *file*.
    ///
    /// This representation can be parsed by future calls to
    /// `read`.  This does **not** close the file after writing.
    ///
    /// Params:
    ///   file = An open file which was opened in text mode.
    ///   spaceAroundDelimiters = The delimiters between keys and
    ///                           values are surrounded by spaces.
    ///
    /// Note: Comments from the original file are not preserved when
    ///       writing the configuration back.
    ///
    void write(ref File file, bool spaceAroundDelimiters = true)
    {
        string del = spaceAroundDelimiters ? " = " : "=";
    
        foreach(string section, string[string] options; m_sections) {
            file.writefln("[%s]", section);

            foreach(string option, string value; options) {
                file.writefln("%s%s%s", option, del, value);
            }
        }
    }

    ///
    unittest
    {
        import std.file : remove;
        import std.stdio : File;

        auto writer = new ConfigParser();
        writer.addSection("general");

        writer.addSection("GUI");
        writer.set("GUI", "WINDOW_WIDTH", "848");
        writer.set("GUI", "WINDOW_HEIGHT", "480");

        auto file = File("test.ini", "w+");
        scope(exit) remove(file.name);
        writer.write(file);

        file.rewind();

        auto reader = new ConfigParser();
        reader.read(file);

        assert(reader.hasSection("general"), "reader does not contain general section");

        assert(reader.hasSection("GUI"), "reader does not contain GUI section");
        assert(reader.get("GUI", "WINDOW_WIDTH") == "848", "reader GUI.WINDOW_WIDTH is not 848");
        assert(reader.getInt("GUI", "WINDOW_WIDTH") == 848, "reader GUI.WINDOW_WIDTH is not 848 (int)");

        assert(reader.get("GUI", "WINDOW_HEIGHT") == "480", "reader GUI.WINDOW_HEIGHT is not 480");
        assert(reader.getInt("GUI", "WINDOW_HEIGHT") == 480, "reader GUI.WINDOW_HEIGHT is not 480 (int)");
    }

  private:

    void parseSectionHeader(ref string line)
    {
        import std.array : appender, assocArray;

        auto sectionHeader = appender!string;
        /* presume that the last character is ] */
        sectionHeader.reserve(line.length - 1);
        string popped = line[1 .. $];

        foreach(c; popped) {
            if (c != ']')
                sectionHeader.put(c);
            else
                break;
        }

        version (DigitalMars) {
            m_currentSection = sectionHeader[];
        } else {
            /* LDC / GNU */
            m_currentSection = sectionHeader.data;
        }

	if (m_currentSection in m_sections && m_strict)
	    throw new DuplicateSectionException(m_currentSection);

	try {
	    this.addSection(m_currentSection);
	} catch (DuplicateSectionException) {
	}
    }

    void parseLine(ref string line)
    {
	import std.string : indexOfAny, toLower, strip;

        ptrdiff_t idx = line.indexOfAny(m_delimiters);
        if (-1 == idx) return;
        string option = line[0 .. idx].dup.strip.toLower;
        string value = line[idx + 1 .. $].dup.strip;

	if (option in m_sections[m_currentSection] && m_strict)
	    throw new DuplicateOptionException(option, m_currentSection);

        m_sections[m_currentSection][option] = value;
    }

    unittest
    {
	import std.exception : assertThrown, assertNotThrown;
	import std.file : remove;

	auto f = File("config.cfg", "w+");
	f.writeln("[section]");
	f.writeln("option = value");
	f.writeln("Option = value");
	f.close();
	scope(exit) remove("config.cfg");

	// Duplicate option
	scope parser = new ConfigParser();
	assertThrown!DuplicateOptionException(parser.read("config.cfg"));

	// Duplicate section
	f = File("config.cfg", "w+");
	f.writeln("[section]");
	f.writeln("option = value");
	f.writeln("[section]");
	f.close();

	assertThrown!DuplicateSectionException(parser.read("config.cfg"));

	// not strict
	scope relaxedParser = new ConfigParser(['='], [], false);

	assertNotThrown!DuplicateSectionException(relaxedParser.read("config.cfg"));
	assert(relaxedParser.hasSection("section"));

	f = File("config.cfg", "a+");
	f.writeln("option = newValue");
	f.close();

	assertNotThrown!DuplicateOptionException(relaxedParser.read("config.cfg"));
	assert(relaxedParser.get("section", "option") == "newValue");
    }
}
