package Kelp::Module::Config;
use Kelp::Base 'Kelp::Module';
use Carp;
use Try::Tiny;
use Test::Deep;

# Extension to look for
attr ext => 'pl';

# Directory where config files are
attr path => sub {
    my $self = shift;
    return [
        $ENV{KELP_CONFIG_DIR},
        'conf',
        $self->app->path,
        $self->app->path . '/conf',
        $self->app->path . '/../conf'
    ]
};

attr separator => sub { qr/\./ };

# Defaults
attr data => sub {
    my $self = shift;

    # Return a big hash with default values
    return {

        # Default charset is UTF-8
        charset => 'UTF-8',

        app_url => 'http://localhost:5000',

        # Modules to load
        modules => [qw/JSON Template/],

        # Module initialization params
        modules_init => {

            # Routes
            Routes => {
                base => ref( $self->app )
            },

            # Template
            Template => {
                paths => [
                    $self->app->path . '/views',
                    $self->app->path . '/../views'
                ]
            },

            # JSON
            JSON => {
                allow_blessed   => 1,
                convert_blessed => 1,
                utf8            => 1
            },
        },

        # List of the middleware to add
        middleware => [],

        # Initializations of the middleware
        middleware_init => {},

    };
};

sub get {
    my ( $self, $path ) = @_;
    return unless $path;
    my @a = split( $self->separator, $path );
    my $val = $self->data;
    for my $chunk (@a) {
        if ( ref($val) eq 'HASH' ) {
            $val = $val->{$chunk};
        }
        else {
            croak "Config path $path breaks at '$chunk'";
        }
    }
    return $val;
}

# Override this one to use other config formats.
sub load {
    my ( $self, $filename ) = @_;

    # Open and read file
    open( my $in, "<:encoding(UTF-8)", $filename )
      or do {
        warn "Can not read config file " . $filename;
        return {};
      };

    my $text = do { local $/ = undef; <$in> };
    close($in);

    my ( $hash, $error );
    {
        local $@;
        my $app = $self->app;
        my $module = $filename;
        $module =~ s/\W/_/g;
        $hash =
            eval "package Kelp::Module::Config::Sandbox::$module;"
          . "use Kelp::Base -strict;"
          . "sub app; local *app = sub { \$app };"
          . "sub include(\$); local *include = sub { \$self->load(\@_) };"
          . $text;
        $error = $@;
    }

    die "Config file $filename parse error: " . $error if $error;
    die "Config file $filename did not return a HASH - $hash"
      unless ref $hash eq 'HASH';

    return $hash;
}

sub process_mode {
    my ( $self, $mode ) = @_;

    my $filename =  sub {
        my @paths = ref( $self->path ) ? @{ $self->path } : ( $self->path );
        for my $path (@paths) {
            next unless defined $path;
            my $filename = sprintf( '%s/%s.%s', $path, $mode, $self->ext );
            return $filename if -r $filename;
        }
    }->();

    unless ( $filename ) {
        if ( $ENV{KELP_CONFIG_WARN} ) {
            my $message =
              $mode eq 'config'
              ? "Main config file not found or not readable"
              : "Config file for mode '$mode' not found or not readable";
            warn $message;
        }
        return;
    }

    my $parsed = {};
    try {
        $parsed = $self->load($filename);
    }
    catch {
        die "Parsing $filename died with error: '${_}'";
    };
    $self->data( _merge( $self->data, $parsed ) );
}

sub build {
    my ( $self, %args ) = @_;

    # Find, parse and merge 'config' and mode files
    for my $name ( 'config', $self->app->mode ) {
        $self->process_mode( $name );
    }

    # Undocumented! Add 'extra' argument to unlock these special features:
    # 1. If the extra argument contains a HASH, it will be merged to the
    #    configuration upon loading.
    # 2. A new attribute '_cfg' will be registered into the app, which has
    # three methods: merge, clear and set. Use them to merge a hash into
    # the configuration, clear it, or set it to a new value. You can do those
    # at any point in the life of the app.
    #
    if ( my $extra = delete $args{extra} ) {
        $self->data( _merge( $self->data, $extra ) ) if ref($extra) eq 'HASH';
        $self->register(

         # A tiny object containing only merge, clear and set. Very useful when
         # you're writing tests and need to add new config options, set the
         # entire config hash to a new value, or clear it completely.
            _cfg => Plack::Util::inline_object(
                merge => sub {
                    $self->data( _merge( $self->data, $_[0] ) );
                },
                clear => sub { $self->data( {} ) },
                set   => sub { $self->data( $_[0] ) }
            )
        );
    }

    $self->register(

        # Return the entire config hash
        config_hash => $self->data,

        # A wrapper arount the get method
        config => sub {
            my ( $app, $path ) = @_;
            return $self->get($path);
        }
    );
}

sub _merge {
    my ( $a, $b, $sigil ) = @_;

    return $b
      if !ref($a)
      || !ref($b)
      || ref($a) ne ref($b);

    if ( ref $a eq 'ARRAY' ) {
        return $b unless $sigil;
        if ( $sigil eq '+' ) {
            for my $e (@$b) {
                push @$a, $e unless grep { eq_deeply( $_, $e ) } @$a;
            }
        }
        else {
            $a = [
                grep {
                    my $e = $_;
                    !grep { eq_deeply( $_, $e ) } @$b
                } @$a
            ];
        }
        return $a;
    }
    elsif ( ref $a eq 'HASH' ) {
        for my $k ( keys %$b ) {

            # If the key is an array then look for a merge sigil
            my $s = ref($b->{$k}) eq 'ARRAY' && $k =~ s/^(\+|\-)// ? $1 : '';

            $a->{$k} =
              exists $a->{$k}
              ? _merge( $a->{$k}, $b->{"$s$k"}, $s )
              : $b->{$k};
        }

        return $a;
    }
    return $b;
}

1;

__END__

=pod

=head1 NAME

Kelp::Module::Config - Configuration for Kelp applications

=head1 DESCRIPTION

This is one of the two modules that are automatically loaded for each and every
Kelp application. The other one is L<Kelp::Module::Routes>. It reads
configuration files containing Perl-style hashes, and merges them depending on
the value of the application's C<mode> attribute.

The main configuration file name is C<config.pl>, and it will be searched in
the C<conf> and C<../conf> directories. You can also set the C<KELP_CONFIG_DIR>
environmental variable with the path to the configuration files.

This module comes with some L<default values|/DEFAULTS>, so if there are no
configuration files found, those values will be used.  Any values from
configuration files will add to or override the default values.

=head1 ORDER

First the module will look for C<conf/config.pl>, then for
C<../conf/config.pl>.  If found, they will be parsed and merged into the
default values.  The same order applies to the I<mode> file too, so if the
application L<mode|Kelp/mode> is I<development>, then C<conf/development.pl>
and C<../conf/development.pl> will be looked for. If found, they will also be
merged to the config hash.

=head1 ACCESSING THE APPLICATION

The application instance can be accessed within the config files via the C<app>
keyword.

    {
        bin_path => app->path . '/bin'
    }

=head1 INCLUDING FILES

To include other config files, one may use the C<include> keyword.

    # config.pl
    {
        modules_init => {
            Template => include('conf/my_template.pl')
        }
    }

    # my_template.pl
    {
        path => 'views/',
        utf8 => 1
    }

Any config file may be included as long as it returns a hashref.

=head1 MERGING

The first configuration file this module will look for is C<config.pl>. This is
where you should keep configuration options that apply to all running
environments.  The mode-specific configuration file will be merged to this
config, and it will take priority. Merging is done as follows:

=over

=item Scalars will always be overwritten.

=item Hashes will be merged.

=item Arrays will be overwritten, except in case when the name of the array contains a
sigil as follows:

=over

=item

C<+> in front of the name will add the elements to the array:

    # in config.pl
    {
        middleware => [qw/Bar Foo/]
    }

    # in development.pl
    {
        '+middleware' => ['Baz']    # Add 'Baz' in development
    }

=cut

=item

C<-> in front of the name will remove the elements from the array:

    # in config.pl
    {
        modules => [qw/Template JSON Logger/]
    }

    # in test.pl
    {
        '-modules' => [qw/Logger/]  # Remove the Logger modules in test mode
    }

=cut

=item

No sigil will cause the array to be completely replaced:

    # in config.pl
    {
        middleware => [qw/Bar Foo/]
    }

    # in cli.pl
    {
        middleware => []    # No middleware in CLI
    }

=cut

=back

Note that the merge sigils only apply to arrays. All other types will keep the
sigil in the key name:

    # config.pl
    {
        modules      => ["+MyApp::Fully::Qualified::Name"],
        modules_init => {
            "+MyApp::Fully::Qualified::Name" => { opt1 => 1, opt2 => 2 }
        }
    }

    # development.pl
    {
        modules_init => {
            "+MyApp::Fully::Qualified::Name" => { opt3 => 3 }
        }
    }

=back

=head1 REGISTERED METHODS

This module registers the following methods into the underlying app:

=head2 config

A wrapper for the C</get> method.

    # Somewhere in the app
    my $pos = $self->config('row.col.position');

    # Gets {row}->{col}->{position} from the config hash

=head2 config_hash

A reference to the entire configuration hash.

    my $pos = $self->config_hash->{row}->{col}->{position};

Using this or C<config> is entirely up to the application developer.

=head3 _cfg

A tiny object that contains only three methods - B<merge>, B<clear> and B<set>.
It allows you to merge values to the config hash, clear it completely or
set it to an entirely new value. This method comes handy when writing tests.

    # Somewhere in a .t file
    my $app = MyApp->new( mode => 'test' );

    my %original_config = %{ $app->config_hash };
    $app->_cfg->merge( { middleware => ['Foo'] } );

    # Now you can test with middleware Foo added to the config

    # Revert to the original configuration
    $app->_cfg->set( \%original_config );

=head1 ATTRIBUTES

This module implements some attributes, which can be overridden by subclasses.

=head2 ext

The file extension of the configuration files. Default is C<pl>.

=head2 separator

A regular expression for the value separator used by L</get>. The default is
C<qr/\./>, i.e. a dot.

=head2 path

Specifies a path, or an array of paths where to look for configuration files.
This is particularly useful when writing tests, because you can set a custom
path to a peculiar configuration.

=head2 data

The hashref with data contained in all of the merged configurations.

=head1 METHODS

The module also implements some methods for parsing the config files, which can
be overridden in extending classes.

=head2 get

C<get($string)>

Get a value from the config using a separated string.

    my $value = $c->get('bar.foo.baz');
    my $same  = $c->get('bar')->{foo}->{baz};
    my $again = $c->data->{bar}->{foo}->{baz};

By default the separator is a dot, but this can be changed via the
L</separator> attribute.

=head2 load

C<load(filename)>

Loads, and parses the file C<$filename> and returns a hash reference.

=head2 process_mode

C<process_mode($mode)>

Finds the file (if it exists) corresponding to C<$mode>, parses it and merges
it into the data. Useful, when you want to process and extra config file during
the application initialization.

    # lib/MyApp.pm
    sub build {
        $self->loaded_modules->{Config}->process_mode( 'more_config' );
    }

=head1 DEFAULTS

This module sets certain default values. All of them may be overridden in any of
the C<conf/> files. It probably pays to view the code of this module and look
and the C<defaults> sub to see what is being set by default, but here is the
short version:

=head2 charset

C<UTF-8>

=head2 app_url

C<http://localhost:5000>

=head2 modules

An arrayref with module names to load on startup. The default value is
C<['JSON', 'Template']>

=head2 modules_init

A hashref with initializations for each of the loaded modules, except this one,
ironically.

=head2 middleware

An arrayref with middleware to load on startup. The default value is an
empty array.

=head2 middleware_init

A hashref with initialization arguments for each of the loaded middleware.

=head1 SUBCLASSING

You can subclass this module and use other types of configuration files
(for example YAML). You need to override the C<ext> attribute
and the C<load> subroutine.

    package Kelp::Module::Config::Custom;
    use Kelp::Parent 'Kelp::Module::Config';

    # Set the config file extension to .cus
    attr ext => 'cus';

    sub load {
        my ( $self, $filename ) = @_;

        # Load $filename, parse it and return a hashref
    }

    1;

Later ...

    # app.psgi
    use MyApp;

    my $app = MyApp->new( config_module => 'Config::Custom' );

    run;

The above example module will look for C<config/*.cus> to load as configuration.

=head1 TESTING

Since the config files are searched in both C<conf/> and C<../conf/>, you can
use the same configuration set of files for your application and for your tests.
Assuming the all of your test will reside in C<t/>, they should be able to load
and find the config files at C<../conf/>.

=head1 ENVIRONMENT VARIABLES

=head2 KELP_CONFIG_WARN

This module will not warn for missing config and mode files. It will
silently load the default configuration hash. Set KELP_CONFIG_WARN to a
true value to make this module warn about missing files.

    $ KELP_CONFIG_WARN=1 plackup app.psgi

=cut
