package JSON::Validator::Store;
use Mojo::Base -base;

use Mojo::Exception;
use Mojo::File qw(path);
use Mojo::JSON;
use Mojo::JSON::Pointer;
use Mojo::UserAgent;
use Mojo::Util qw(url_unescape);
use JSON::Validator::Schema;
use JSON::Validator::URI qw(uri);
use JSON::Validator::Util qw(data_section str2data);
use Scalar::Util qw(blessed);

use constant DEBUG         => $ENV{JSON_VALIDATOR_DEBUG} && 1;
use constant BUNDLED_PATH  => path(path(__FILE__)->dirname, 'cache')->to_string;
use constant CASE_TOLERANT => File::Spec->case_tolerant;

die $@ unless eval q(package JSON::Validator::Exception; use Mojo::Base 'Mojo::Exception'; 1);

has cache_paths => sub { [split(/:/, $ENV{JSON_VALIDATOR_CACHE_PATH} || ''), BUNDLED_PATH] };
has schemas     => sub { +{} };

has ua => sub {
  my $ua = Mojo::UserAgent->new;
  $ua->proxy->detect;
  return $ua->max_redirects(3);
};

sub add {
  my ($self, $id, $schema) = @_;
  $id =~ s!(.)#$!$1!;
  $self->schemas->{$id} = $schema;
  return $id;
}

sub exists {
  my ($self, $id) = @_;
  return undef unless defined $id;
  $id =~ s!(.)#$!$1!;
  return $self->schemas->{$id} && $id;
}

sub get {
  my ($self, $id) = @_;
  return undef unless defined $id;
  $id =~ s!(.)#$!$1!;
  return $self->schemas->{$id};
}

sub load {
  return
       $_[0]->_load_from_url($_[1])
    || $_[0]->_load_from_data($_[1])
    || $_[0]->_load_from_text($_[1])
    || $_[0]->_load_from_file($_[1])
    || $_[0]->_load_from_app($_[1])
    || $_[0]->get($_[1])
    || _raise(qq(Unable to load schema "$_[1]".));
}

sub resolve {
  my ($self, $ref, $curr) = @_;
  $curr //= {base_url => ''};

  my ($base_url, $fragment) = split '#', $ref;
  my $abs_url = uri($base_url)->fragment($fragment);
  $abs_url  = uri $abs_url, $curr->{base_url} if $curr->{base_url} and !$abs_url->is_abs;
  $fragment = '' unless defined $fragment;
  $base_url ||= $curr->{base_url} || '';

  warn "[JSON::Validator] Resolve curr: ref=$ref,@{[map qq($_=$curr->{$_}), sort keys %$curr]}\n" if DEBUG;

  my $state = {base_url => $base_url, fragment => $fragment, source => 'unknown'};
  if (defined(my $schema = $self->schemas->{$abs_url})) {
    @$state{qw(base_url id root schema source)} = ($abs_url, $abs_url, $schema, $schema, 'schema/abs_url');
  }
  elsif (defined(my $root = $self->schemas->{$base_url})) {
    @$state{qw(base_url id root source)} = ($base_url, $base_url, $root, 'schema/base_url');
  }
  elsif ($base_url) {
    $base_url = uri $base_url, $curr->{base_url} if $curr->{base_url};
    my $id = $self->load($base_url);
    @$state{qw(base_url id root source)} = ($id, $id, $self->get($id), 'load');
    $state->{root} = $self->get($id);
  }
  else {
    @$state{qw(id root source)} = ('', $curr->{root}, 'root');
  }

  $fragment =~ s!%2f!~1!;    # /
  $fragment =~ s!%7e!~0!;    # ~
  $fragment = url_unescape $fragment;
  $state->{schema} //= length $fragment ? Mojo::JSON::Pointer->new($state->{root})->get($fragment) : $state->{root};
  _raise(qq[Unable to resolve "$ref" from "$state->{base_url}". ($state->{source})]) unless defined $state->{schema};

  $state->{$_} //= $curr->{$_} for keys %$curr;    # pass on original information
  warn "[JSON::Validator] Resolve state: @{[map qq($_=$state->{$_}), sort keys %$state]}\n" if DEBUG;
  return $state;
}

sub _add {
  my ($self, $id, $schema) = @_;
  $id = $self->add($id => $schema);

  if (ref $schema eq 'HASH') {
    return
        $schema->{'$id'} ? $self->add($schema->{'$id'} => $schema)
      : $schema->{id}    ? $self->add($schema->{id} => $schema)
      :                    $id;
  }

  return $id;
}

sub _load_from_app {
  return undef unless $_[1] =~ m!^/!;

  my ($self, $url) = @_;
  my $id;
  return undef unless $self->ua->server->app;
  return undef if blessed $url and !$url->can('scheme');
  return $id if $id = $self->exists($url);

  my $tx  = $self->ua->get($url);
  my $err = $tx->error && $tx->error->{message};
  _raise("GET $url: $err")                      if $err;
  warn "[JSON::Validator] Load from app $url\n" if DEBUG;
  return $self->_add($url => str2data $tx->res->body);
}

sub _load_from_data {
  return undef unless $_[1] =~ m!^data://([^/]*)/(.*)!;

  my ($self, $url) = @_;
  my $id;
  return $id if $id = $self->exists($url);

  my ($class, $file) = ($1, $2);    # data://([^/]*)/(.*)
  my $text = data_section $class, $file, {encoding => 'UTF-8'};
  _raise("Could not find $url") unless $text;
  warn "[JSON::Validator] Load from data $file in $class\n" if DEBUG;
  return $self->_add($url => str2data $text);
}

sub _load_from_file {
  my ($self, $file) = @_;

  $file =~ s!^file://!!;
  $file =~ s!#$!!;
  $file = path(split '/', url_unescape $file);
  return undef unless -e $file;

  $file = $file->realpath;
  my $id = uri()->new->scheme('file')->host('')->path(CASE_TOLERANT ? lc $file : "$file");
  warn "[JSON::Validator] Load from file $file\n" if DEBUG;
  return $self->exists($id) || $self->_add($id => str2data $file->slurp);
}

sub _load_from_text {
  my ($self, $text) = @_;
  my $is_scalar_ref = ref $text eq 'SCALAR';
  return undef unless $is_scalar_ref or $text =~ m!^\s*(?:---|\{)!s;

  my $id = uri->from_data($is_scalar_ref ? $$text : $text);
  warn "[JSON::Validator] Load from text $id\n" if DEBUG;
  return $self->exists($id) || $self->_add($id => str2data $is_scalar_ref ? $$text : $text);
}

sub _load_from_url {
  return undef unless $_[1] =~ m!^https?://!;

  my ($self, $url) = @_;
  my $id;
  return $id if $id = $self->exists($url);

  $url = uri($url)->fragment(undef);
  return $id if $id = $self->exists($url);

  my $cache_path = $self->cache_paths->[0];
  my $cache_file = Mojo::Util::md5_sum("$url");
  for (@{$self->cache_paths}) {
    my $path = path $_, $cache_file;
    warn "[JSON::Validator] Load from cache $path\n"  if DEBUG and -r $path;
    return $self->_add($url => str2data $path->slurp) if -r $path;
  }

  my $tx  = $self->ua->get($url);
  my $err = $tx->error && $tx->error->{message};
  _raise("GET $url: $err") if $err;

  if ($cache_path and $cache_path ne BUNDLED_PATH and -w $cache_path) {
    $cache_file = path $cache_path, $cache_file;
    $cache_file->spurt($tx->res->body);
  }

  warn "[JSON::Validator] Load from URL $url\n" if DEBUG;
  return $self->_add($url => str2data $tx->res->body);
}

sub _raise { die JSON::Validator::Exception->new(@_)->trace }

1;

=encoding utf8

=head1 NAME

JSON::Validator::Store - Load and caching JSON schemas

=head1 SYNOPSIS

  use JSON::Validator;
  my $jv = JSON::Validator->new;
  $jv->store->add("urn:uuid:ee564b8a-7a87-4125-8c96-e9f123d6766f" => {...});
  $jv->store->load("http://api.example.com/my/schema.json");

=head1 DESCRIPTION

L<JSON::Validator::Store> is a class for loading and caching JSON-Schemas.

=head1 ATTRIBUTES

=head2 cache_paths

  my $store     = $store->cache_paths(\@paths);
  my $array_ref = $store->cache_paths;

A list of directories to where cached specifications are stored. Defaults to
C<JSON_VALIDATOR_CACHE_PATH> environment variable and the specs that is bundled
with this distribution.

C<JSON_VALIDATOR_CACHE_PATH> can be a list of directories, each separated by ":".

See L<JSON::Validator/Bundled specifications> for more details.

=head2 schemas

  my $hash_ref = $store->schemas;
  my $store = $store->schemas({});

Hold the schemas as data structures. The keys are schema "id".

=head2 ua

  my $ua    = $store->ua;
  my $store = $store->ua(Mojo::UserAgent->new);

Holds a L<Mojo::UserAgent> object, used by L</schema> to load a JSON schema
from remote location.

The default L<Mojo::UserAgent> will detect proxy settings and have
L<Mojo::UserAgent/max_redirects> set to 3.

=head1 METHODS

=head2 add

  my $normalized_id = $store->add($id => \%schema);

Used to add a schema data structure. Note that C<$id> might not be the same as
C<$normalized_id>.

=head2 exists

  my $normalized_id = $store->exists($id);

Returns a C<$normalized_id> if it is present in the L</schemas>.

=head2 get

  my $schema = $store->get($normalized_id);

Used to retrieve a C<$schema> added by L</add> or L</load>.

=head2 load

  my $normalized_id = $store->load('https://...');
  my $normalized_id = $store->load('data://main/foo.json');
  my $normalized_id = $store->load('---\nid: yaml');
  my $normalized_id = $store->load('{"id":"yaml"}');
  my $normalized_id = $store->load(\$text);
  my $normalized_id = $store->load('/path/to/foo.json');
  my $normalized_id = $store->load('file:///path/to/foo.json');
  my $normalized_id = $store->load('/load/from/ua-server-app');

Can load a C<$schema> from many different sources. The input can be a string or
a string-like object, and the L</load> method will try to resolve it in the
order listed in above.

Loading schemas from C<$text> will generate an C<$normalized_id> in L</schemas>
looking like "urn:text:$text_checksum". This might change in the future!

Loading files from disk will result in a C<$normalized_id> that always start
with "file://".

Loading can also be done with relative path, which will then load from:

  $store->ua->server->app;

This method is EXPERIMENTAL, but unlikely to change significantly.

=head2 resolve

  $hash_ref = $store->resolve($url, \%defaults);

Takes a C<$url> (can also be a file, urn, ...) with or without a fragment and
returns this structure about the schema:

  {
    base_url => $str,  # the part before the fragment in the $url
    fragment => $str,  # fragment part of the $url
    id       => $str,  # store ID
    root     => ...,   # the root schema
    schema   => ...,   # the schema inside "root" if fragment is present
  }

This method is EXPERIMENTAL and can change without warning.

=head1 SEE ALSO

L<JSON::Validator>.

=cut