/*******************************************************************************

    Definitions for Exceptions used by the config module.

    Copyright:
        Copyright (c) 2019-2022 BOSAGORA Foundation
        All rights reserved.

    License:
        MIT License. See LICENSE for details.

*******************************************************************************/

module configy.Exceptions;

import configy.Utils;

import dyaml.exception;
import dyaml.node;

import std.algorithm : filter, map;
import std.format;
import std.string : soundexer;

/*******************************************************************************

    Base exception type thrown by the config parser

    Whenever dealing with Exceptions thrown by the config parser, catching
    this type will allow to optionally format with colors:
    ```
    try
    {
        auto conf = parseConfigFile!Config(cmdln);
        // ...
    }
    catch (ConfigException exc)
    {
        writeln("Parsing the config file failed:");
        writelfln(isOutputATTY() ? "%S" : "%s", exc);
    }
    ```

*******************************************************************************/

public abstract class ConfigException : Exception
{
    /// Position at which the error happened
    public Mark yamlPosition;

    /// The path at which the key resides
    public string path;

    /// If non-empty, the key under 'path' which triggered the error
    /// If empty, the key should be considered part of 'path'
    public string key;

    /// Constructor
    public this (string path, string key, Mark position,
                 string file = __FILE__, size_t line = __LINE__)
        @safe pure nothrow @nogc
    {
        super(null, file, line);
        this.path = path;
        this.key = key;
        this.yamlPosition = position;
    }

    /// Ditto
    public this (string path, Mark position,
                 string file = __FILE__, size_t line = __LINE__)
        @safe pure nothrow @nogc
    {
        this(path, null, position, file, line);
    }

    /***************************************************************************

        Overrides `Throwable.toString` and its sink overload

        It is quite likely that errors from this module may be printed directly
        to the end user, who might not have technical knowledge.

        This format the error in a nicer format (e.g. with colors),
        and will additionally provide a stack-trace if the `ConfigFillerDebug`
        `debug` version was provided.

        Format_chars:
          The default format char ("%s") will print a regular message.
          If an uppercase 's' is used ("%S"), colors will be used.

        Params:
          sink = The sink to send the piece-meal string to
          spec = See https://dlang.org/phobos/std_format_spec.html

    ***************************************************************************/

    public override string toString () scope
    {
        // Need to be overriden otherwise the overload is shadowed
        return super.toString();
    }

    /// Ditto
    public override void toString (scope void delegate(in char[]) sink) const scope
        @trusted
    {
        // This breaks the type system, as it blindly trusts a delegate
        // However, the type system lacks a way to sanely build an utility
        // which accepts a delegate with different qualifiers, so this is the
        // less evil approach.
        this.toString(cast(SinkType) sink, FormatSpec!char("%s"));
    }

    /// Ditto
    public void toString (scope SinkType sink, in FormatSpec!char spec)
        const scope @safe
    {
        import core.internal.string : unsignedToTempString;

        const useColors = spec.spec == 'S';
        char[20] buffer = void;

        if (useColors) sink(Yellow);
        sink(this.yamlPosition.name);
        if (useColors) sink(Reset);

        sink("(");
        if (useColors) sink(Cyan);
        sink(unsignedToTempString(this.yamlPosition.line, buffer));
        if (useColors) sink(Reset);
        sink(":");
        if (useColors) sink(Cyan);
        sink(unsignedToTempString(this.yamlPosition.column, buffer));
        if (useColors) sink(Reset);
        sink("): ");

        if (this.path.length || this.key.length)
        {
            if (useColors) sink(Yellow);
            sink(this.path);
            if (this.path.length && this.key.length)
                sink(".");
            sink(this.key);
            if (useColors) sink(Reset);
            sink(": ");
        }

        this.formatMessage(sink, spec);

        debug (ConfigFillerDebug)
        {
            sink("\n\tError originated from: ");
            sink(this.file);
            sink("(");
            sink(unsignedToTempString(line, buffer));
            sink(")");

            if (!this.info)
                return;

            () @trusted nothrow
            {
                try
                {
                    sink("\n----------------");
                    foreach (t; info)
                    {
                        sink("\n"); sink(t);
                    }
                }
                // ignore more errors
                catch (Throwable) {}
            }();
        }
    }

    /// Hook called by `toString` to simplify coloring
    protected abstract void formatMessage (
        scope SinkType sink, in FormatSpec!char spec)
        const scope @safe;
}

/// A configuration exception that is only a single message
package final class ConfigExceptionImpl : ConfigException
{
    public this (string msg, Mark position,
                 string file = __FILE__, size_t line = __LINE__)
        @safe pure nothrow @nogc
    {
        this(msg, null, null, position, file, line);
    }

    public this (string msg, string path, string key, Mark position,
                 string file = __FILE__, size_t line = __LINE__)
        @safe pure nothrow @nogc
    {
        super(path, key, position, file, line);
        this.msg = msg;
    }

    protected override void formatMessage (
        scope SinkType sink, in FormatSpec!char spec)
        const scope @safe
    {
        sink(this.msg);
    }
}

/// Exception thrown when the type of the YAML node does not match the D type
package final class TypeConfigException : ConfigException
{
    /// The actual (in the YAML document) type of the node
    public string actual;

    /// The expected (as specified in the D type) type
    public string expected;

    /// Constructor
    public this (Node node, string expected, string path, string key = null,
                 string file = __FILE__, size_t line = __LINE__)
        @safe nothrow
    {
        this(node.nodeTypeString(), expected, path, key, node.startMark(),
             file, line);
    }

    /// Ditto
    public this (string actual, string expected, string path, string key,
                 Mark position, string file = __FILE__, size_t line = __LINE__)
        @safe pure nothrow @nogc
    {
        super(path, key, position, file, line);
        this.actual = actual;
        this.expected = expected;
    }

    /// Format the message with or without colors
    protected override void formatMessage (
        scope SinkType sink, in FormatSpec!char spec)
        const scope @safe
    {
        const useColors = spec.spec == 'S';

        const fmt = "Expected to be of type %s, but is a %s";

        if (useColors)
            formattedWrite(sink, fmt, this.expected.paint(Green), this.actual.paint(Red));
        else
            formattedWrite(sink, fmt, this.expected, this.actual);
    }
}

/// Similar to a `TypeConfigException`, but specific to `Duration`
package final class DurationTypeConfigException : ConfigException
{
    /// The list of valid fields
    public immutable string[] DurationSuffixes = [
        "weeks", "days",  "hours",  "minutes", "seconds",
        "msecs", "usecs", "hnsecs", "nsecs",
    ];

    /// Actual type of the node
    public string actual;

    /// Constructor
    public this (Node node, string path, string file = __FILE__, size_t line = __LINE__)
        @safe nothrow
    {
        super(path, null, node.startMark(), file, line);
        this.actual = node.nodeTypeString();
    }

    /// Format the message with or without colors
    protected override void formatMessage (
        scope SinkType sink, in FormatSpec!char spec)
        const scope @safe
    {
        const useColors = spec.spec == 'S';

        const fmt = "Field is of type %s, but expected a mapping with at least one of: %-(%s, %)";
        if (useColors)
            formattedWrite(sink, fmt, this.actual.paint(Red),
                           this.DurationSuffixes.map!(s => s.paint(Green)));
        else
            formattedWrite(sink, fmt, this.actual, this.DurationSuffixes);
    }
}

/// Exception thrown when an unknown key is found in strict mode
public class UnknownKeyConfigException : ConfigException
{
    /// The list of valid field names
    public immutable string[] fieldNames;

    /// Constructor
    public this (string path, string key, immutable string[] fieldNames,
                 Mark position, string file = __FILE__, size_t line = __LINE__)
        @safe pure nothrow @nogc
    {
        super(path, key, position, file, line);
        this.fieldNames = fieldNames;
    }

    /// Format the message with or without colors
    protected override void formatMessage (
        scope SinkType sink, in FormatSpec!char spec)
        const scope @safe
    {
        const useColors = spec.spec == 'S';

        // Try to find a close match, as the error is likely a typo
        // This is especially important when the config file has a large
        // number of fields, where the message is otherwise near-useless.
        const origSound = soundexer(this.key);
        auto matches = this.fieldNames.filter!(f => f.soundexer == origSound);
        const hasMatch  = !matches.save.empty;

        if (hasMatch)
        {
            const fmt = "Key is not a valid member of this section. Did you mean: %-(%s, %)";
            if (useColors)
                formattedWrite(sink, fmt, matches.map!(f => f.paint(Green)));
            else
                formattedWrite(sink, fmt, matches);
        }
        else
        {
            // No match, just print everything
            const fmt = "Key is not a valid member of this section. There are %s valid keys: %-(%s, %)";
            if (useColors)
                formattedWrite(sink, fmt, this.fieldNames.length.paint(Yellow),
                               this.fieldNames.map!(f => f.paint(Green)));
            else
                formattedWrite(sink, fmt, this.fieldNames.length, this.fieldNames);
        }
    }
}

/// Exception thrown when a required key is missing
public class MissingKeyException : ConfigException
{
    /// Constructor
    public this (string path, string key, Mark position,
                 string file = __FILE__, size_t line = __LINE__)
        @safe pure nothrow @nogc
    {
        super(path, key, position, file, line);
    }

    /// Format the message with or without colors
    protected override void formatMessage (
        scope SinkType sink, in FormatSpec!char spec)
        const scope @safe
    {
        sink("Required key was not found in configuration or command line arguments");
    }
}

/// Wrap an user-thrown Exception that happened in a Converter/ctor/fromString
public class ConstructionException : ConfigException
{
    /// Constructor
    public this (Exception next, string path, Mark position,
                 string file = __FILE__, size_t line = __LINE__)
        @safe pure nothrow @nogc
    {
        super(path, position, file, line);
        this.next = next;
    }

    /// Format the message with or without colors
    protected override void formatMessage (
        scope SinkType sink, in FormatSpec!char spec)
        const scope @trusted
    {
        if (auto dyn = cast(ConfigException) this.next)
            dyn.toString(sink, spec);
        else
            sink(this.next.message);
    }
}
