#------------------------------------------------------------------------------
#$Author: andrius $
#$Date: 2020-05-04 12:38:12 -0400 (Mon, 04 May 2020) $ 
#$Revision: 6752 $
#$URL: svn://saulius-grazulis.lt/restful/tags/v0.15.2/lib/RestfulDB/JSON.pm $
#------------------------------------------------------------------------------
#*
#  Read/write JSON representation for data from RestfulDB.
#**

package RestfulDB::JSON;
use warnings;
use strict;

use Encode qw( decode );
use IO::String;
use JSON -support_by_pp;
use LWP::Simple;
use Object::Lazy;
use POSIX qw( strftime );
use Scalar::Util qw( blessed );

use RestfulDB::Exception;
use RestfulDB::JSONAPI qw( warnings2jsonapi );
use RestfulDB::SQL qw( is_blob is_numerical );

require Exporter;
our @ISA = qw( Exporter );
our @EXPORT_OK = qw(
    data2json
    error2json
    json2data
);

our $MEDIA_TYPE = 'application/json';

sub data2json
{
    my ($db, $items, $db_table, $options) = @_;

    my $json_struct = {
        data => records2json( $db, $items, $db_table, $options ),
        metadata => {
            time_stamp =>
                strftime( '%Y-%m-%dT%H:%M:%SZ', gmtime( time() ) ),
            warnings2jsonapi( @{$options->{warnings}} ),
        }
    };

    $json_struct->{metadata}{data_returned} =
        scalar @{$json_struct->{data}};
    $json_struct->{metadata}{data_matched} =
        defined $options->{data_matched}
            ? $options->{data_matched} : scalar @{$json_struct->{data}};

    if( $options->{request_uri} ) {
        $json_struct->{metadata}{query}{representation} =
            decode( 'utf8', $options->{request_uri} );
    }

    return JSON->new()->canonical->encode( $json_struct );
}

sub json2data
{
    my ($json, $options) = @_;

    my $jsonstruct;
    eval { $jsonstruct = JSON->new()->allow_bignum()->decode( $json ) };
    InputException->throw( $@ ) if $@;

    return jsonstruct2data( $jsonstruct, $options );
}

sub jsonstruct2data
{
    my ($json, $options) = @_;

    $options = {} unless $options;
    my( $db ) = ( $options->{db} );

    my @data;
    for my $item (@{$json->{data}}) {
        my $columns = {};
        if( exists $item->{attributes} ) {
            my $column_types;
            if( $db ) {
                $column_types = $db->get_column_type_hash( $item->{type} );
            }
            for my $field (sort keys %{$item->{attributes}}) {
                if( ref $item->{attributes}{$field} eq 'HASH' &&
                    exists $item->{attributes}{$field}{type} ) {
                    # This is possibly "our" data structure, therefore,
                    # attempt to translate it into values of related
                    # tables:
                        my $inner = jsonstruct2data(
                            { data => [ $item->{attributes}{$field} ] }, $options );
                        $columns->{$field}{fk_target} = $inner->[0];
                } elsif( ref $item->{attributes}{$field} eq 'HASH' &&
                         exists $item->{attributes}{$field}{links} &&
                         exists $item->{attributes}{$field}{links}{self} ) {
                    # If an URL is provided, we may try to resolve it
                    my $link = $item->{attributes}{$field}{links}{self};
                    $columns->{$field}{value} = $link;
                    $columns->{$field}{handle} =
                        Object::Lazy->new(
                            sub {
                                my $html = get( $link ) ||
                                    die "Cannot retrieve '$link'";
                                return IO::String->new( $html );
                            }
                        );
                } elsif( ref $item->{attributes}{$field} eq 'ARRAY' &&
                         !grep { ref $_ } @{$item->{attributes}{$field}} ) {
                    # Flat arrays typically carry SET values
                    $columns->{$field}{value} = $item->{attributes}{$field};
                } elsif( ref $item->{attributes}{$field} &&
                         !blessed $item->{attributes}{$field} ) {
                    # If the structure is not "our", store it as JSON
                    # string or file:
                    my $sub_json =
                        JSON->new()->canonical->allow_bignum->encode(
                            $item->{attributes}{$field} );
                    if( $column_types && is_blob( $column_types->{$field} ) ) {
                        $columns->{$field}{value} = undef;
                        $columns->{$field}{handle} = IO::String->new( $sub_json );
                    } else {
                        $columns->{$field}{value} = $sub_json;
                    }
                } else {
                    $columns->{$field}{value} = $item->{attributes}{$field};
                }
            }
        }

        my $related_tables = {};
        if( exists $item->{relationships} ) {
            for my $table (sort keys %{$item->{relationships}}) {
                $related_tables->{$table} =
                    jsonstruct2data( $item->{relationships}{$table}, $options );
            }
        }

        my $action = $options->{default_action}
                        ? $options->{default_action} : 'insert';
        push @data,
             {
                 metadata => { table_name => $item->{type}, action => $action },
                 columns => $columns,
                 related_tables => $related_tables,
             };
    }

    return \@data;
}

sub records2json
{
    my ($db, $items, $db_table, $options) = @_;

    my( $web_base, $skip_related, $skip_links, $parent ) =
        ( $options->{web_base},
          $options->{skip_related},
          $options->{skip_links},
          $options->{parent} );

    my @json_items;
    for my $item (@$items) {
        my $id_column = $db->get_id_column( $item->{metadata}{table_name} );

        my %fields = %{$item->{columns}};
        next if !exists $fields{$id_column}->{value};
        my $id = $fields{$id_column}->{value};

        if( defined $id &&
            is_numerical( $fields{$id_column}->{sqltype} ) ) {
            $id = $id + 0;
        }

        my $json_item = {
            type => $db_table,
            id => $id,
            attributes => fields2attributes( $db,
                                             \%fields,
                                             $db_table,
                                             $options )
        };

        if( !$skip_links ) {
            my $url = "$web_base/$db_table/$id";
            $json_item->{links} = { self => $url };
        }

        if( $item->{related_tables} && !$skip_related ) {
            my %related = %{ $item->{related_tables} };
            $json_item->{relationships} = {
                map { $_ => { data => records2json( $db,
                                                    $related{$_},
                                                    $_,
                                                    { %$options,
                                                      parent => $item } ) } }
                      keys %related
            };
        }

        push @json_items, $json_item;
    }

    return \@json_items;
}

sub fields2attributes
{
    my ($db, $fields, $db_table, $options) = @_;

    my $id_column = $db->get_id_column( $db_table );
    my $attributes = {};
    for my $key (keys %$fields) {

        my $value;
        if( exists $fields->{$key}{value} ) {
            $value = $fields->{$key}{value};
        }

        # MySQL DBI driver seems to retrieve numbers as strings from
        # a MySQL database, therefore, these values have to be
        # converted to numeric.
        if( defined $value &&
            is_numerical( $fields->{$key}{sqltype} ) ) {
            $value = $value + 0;
        }

        ## DISCUSS: let's add the ID column for now. It is also
        ## explicitly included in "id" field of top-level object, thus
        ## we may want to exclude it from the attributes.
        # next if $key eq $id_column;
        if( $fields->{$key}{fk_target} ) {
            my $fk_table = $fields->{$key}{fk_target}{metadata}{table_name};
            my $fk_id_column = $db->get_id_column( $fk_table );

            if( !$options->{parent} ||
                $options->{parent}{metadata}{table_name} ne $fk_table ||
                !defined $options->{parent}{columns}{$fk_id_column}{value} ||
                !defined $fields->{$key}{fk_target}{columns}{$fk_id_column}{value} ||
                $options->{parent}{columns}{$fk_id_column}{value} ne
                $fields->{$key}{fk_target}{columns}{$fk_id_column}{value} ) {
            
                my $children = records2json( $db,
                                             [ $fields->{$key}{fk_target} ],
                                             $fk_table,
                                             { %$options,
                                               skip_related => 1,
                                               skip_links => 1 } );
                # N:1 related data will always result in a single child:
                $attributes->{$key} = $children->[0];
            } else {
                $attributes->{$key} = $value;
            }
        } elsif( exists $fields->{$key}{urlvalue} ) {
            $attributes->{$key} =
                { links => { self => $fields->{$key}{urlvalue} } };
        } elsif( defined $value && lc $fields->{$key}{sqltype} eq 'set' ) {
            $attributes->{$key} = [ split ',', $value ];
        } else {
            $attributes->{$key} = $value;
        }
    }

    return $attributes;
}

sub error2json
{
    my( $cgi, $error ) = @_;

    my $status = $error->isa( RestfulDB::Exception:: )
                    ? $error->http_status : 500;

    chomp $error;
    print $cgi->header( -type => $MEDIA_TYPE,
                        -status => $status,
                        -charset => 'UTF-8' ),
          JSON->new()->canonical->encode( { errors => [ { detail => "$error" } ] } );
}

1;
