package autobox;

use 5.008;

use strict;
use warnings;

use Carp;
use XSLoader;
use Scalar::Util;
use Scope::Guard;
use Storable;

# XXX this declaration must be on a single line
# https://metacpan.org/pod/version#How-to-declare()-a-dotted-decimal-version
use version 0.77; our $VERSION = version->declare('v3.0.2');

XSLoader::load 'autobox', $VERSION;

use autobox::universal (); # don't import

############################################# PRIVATE ###############################################

my $SEQ            = 0;  # unique identifier for synthetic classes
my $BINDINGS_CACHE = {}; # hold a reference to the bindings hashes
my $CLASS_CACHE    = {}; # reuse the same synthetic class if the @isa has been seen before

# all supported types
# the boolean indicates whether the type is a real internal type (as opposed to a virtual type)
my %TYPES = (
    UNDEF     => 1,
    INTEGER   => 1,
    FLOAT     => 1,
    NUMBER    => 0,
    STRING    => 1,
    SCALAR    => 0,
    ARRAY     => 1,
    HASH      => 1,
    CODE      => 1,
    UNIVERSAL => 0
);

# type hierarchy: keys are parents, values are (depth, children) pairs
my %ISA = (
    UNIVERSAL => [ 0, [ qw(SCALAR ARRAY HASH CODE) ] ],
    SCALAR    => [ 1, [ qw(STRING NUMBER) ] ],
    NUMBER    => [ 2, [ qw(INTEGER FLOAT) ] ]
);

# default bindings when no args are supplied
my %DEFAULT = (
    SCALAR => 'SCALAR',
    ARRAY  => 'ARRAY',
    HASH   => 'HASH',
    CODE   => 'CODE'
);

# reinvent List::MoreUtils::uniq to keep the dependencies light - return a reference
# to an array containing (in order) the unique members of the supplied list
sub _uniq($) {
    my $list = shift;
    my (%seen, @uniq);

    for my $element (@$list) {
        next if ($seen{$element});
        push @uniq, $element;
        $seen{$element} = 1;
    }

    return [ @uniq ];
}

# create a shim class - actual methods are implemented by the classes in its @ISA
#
# as an optimization, return the previously-generated class
# if we've seen the same (canonicalized) @isa before
sub _generate_class($) {
    my $isa = _uniq(shift);

    # As an optimization, simply return the class if there's only one.
    # This speeds up method lookup as the method can (often) be found directly in the stash
    # rather than in the ISA hierarchy with its attendant AUTOLOAD-related overhead
    if (@$isa == 1) {
        my $class = $isa->[0];
        _make_class_accessor($class); # NOP if it has already been added
        return $class;
    }

    my $key = Storable::freeze($isa);

    return $CLASS_CACHE->{$key} ||= do {
        my $class = sprintf('autobox::_shim_%d_', ++$SEQ);
        my $synthetic_class_isa = _get_isa($class); # i.e. autovivify

        @$synthetic_class_isa = @$isa;
        _make_class_accessor($class);
        $class;
    };
}

# expose the autobox class (for can, isa etc.)
# https://rt.cpan.org/Ticket/Display.html?id=55565
sub _make_class_accessor ($) {
    my $class = shift;
    return unless (defined $class);

    {
        no strict 'refs';
        *{"$class\::autobox_class"} = sub { $class } unless (*{"$class\::autobox_class"}{CODE});
    }
}

# pretty-print the bindings hash by showing its values as the inherited classes rather than the synthetic class
sub _pretty_print($) {
    my $hash = { %{ shift() } }; # clone the hash to isolate it from the original

    # reverse() turns a hash that maps an isa signature to a class name into a hash that maps
    # a class name into a boolean
    my %synthetic = reverse(%$CLASS_CACHE);

    for my $type (keys %$hash) {
        my $class = $hash->{$type};
        $hash->{$type} = $synthetic{$class} ? [ _get_isa($class) ] : [ $class ];
    }

    return $hash;
}

# default sub called when the DEBUG option is supplied with a true value
# prints the assigned bindings for the current scope
sub _debug ($) {
    my $bindings = shift;
    require Data::Dumper;
    no warnings qw(once);
    local ($|, $Data::Dumper::Indent, $Data::Dumper::Terse, $Data::Dumper::Sortkeys) = (1, 1, 1, 1);
    print STDERR Data::Dumper::Dumper($bindings), $/;
}

# return true if $ref ISA $class - works with non-references, unblessed references and objects
# we can't use UNIVERSAL::isa to test if a value is an array ref;
# if the value is 'ARRAY', and that package exists, then UNIVERSAL::isa('ARRAY', 'ARRAY') is true!
sub _isa($$) {
    my ($ref, $class) = @_;
    return Scalar::Util::blessed($ref) ? $ref->isa($class) : ref($ref) eq $class;
}

# get/autovivify the @ISA for the specified class
sub _get_isa($) {
    my $class = shift;
    my $isa   = do {
        no strict 'refs';
        *{"$class\::ISA"}{ARRAY};
    };
    return wantarray ? @$isa : $isa;
}

# install a new set of bindings for the current scope
#
# XXX this could be refined to reuse the same hashref if its contents have already been seen,
# but that requires each (frozen) hash to be cached; at best, it may not be much of a win, and at
# worst it will increase bloat
sub _install ($) {
    my $bindings = shift;
    $^H{autobox} = $bindings;
    $BINDINGS_CACHE->{$bindings} = $bindings; # keep the $bindings hash alive
}

# return the supplied class name or a new class name made by appending the specified
# type to the namespace prefix
sub _expand_namespace($$) {
    my ($class, $type) = @_;

    # make sure we can weed out classes that are empty strings or undef by returning an empty list
    Carp::confess("_expand_namespace not called in list context") unless (wantarray);

    if ((defined $class) && ($class ne '')) {
        ($class =~ /::$/) ? "$class$type" : $class;
    } else { # return an empty list
        ()
    }
}

############################################# PUBLIC (Methods) ###############################################

# enable some flavour of autoboxing in the current scope
sub import {
    my $class = shift;
    my %args = ((@_ == 1) && _isa($_[0], 'HASH')) ? %{shift()} : @_; # hash or hashref
    my $debug = delete $args{DEBUG};

    %args = %DEFAULT unless (%args); # wait till DEBUG has been deleted

    # normalize %args so that it has a (possibly empty) array ref for all types, both real and virtual
    for my $type (keys %TYPES) {
        if (exists $args{$type}) { # exists() as the value may be undef (or ''), meaning "don't default this type"
            if (_isa($args{$type}, 'ARRAY')) {
                $args{$type} = [ @{$args{$type}} ]; # clone the array ref to isolate changes
            } else {
                $args{$type} = [ $args{$type} ];
            }
        } else {
            $args{$type} = [];
        }
    }

    # if supplied, fill in defaults for unspecified SCALAR, ARRAY, HASH and CODE bindings
    # must be done before the virtual type expansion below as one of the defaults, SCALAR, is a
    # virtual type

    my $default = delete $args{DEFAULT};

    if ($default) {
        $default = [ $default ] unless (_isa($default, 'ARRAY')); # no need to clone as we flatten it each time

        for my $type (keys %DEFAULT) {
            # don't default if a binding has already been supplied; this may include an undef value meaning
            # "don't default this type" e.g.
            #
            #     use autobox
            #         DEFAULT => 'MyDefault',
            #         HASH    => undef;
            #
            # undefs are winnowed out by _expand_namespace

            next if (@{$args{$type}});
            push @{$args{$type}}, map { _expand_namespace($_, $type) } @$default;
        }
    }

    # expand the virtual type "macros" from the root to the leaves
    for my $vtype (sort { $ISA{$a}->[0] <=> $ISA{$b}->[0] } keys %ISA) {
        next unless ($args{$vtype});

        my @types = @{$ISA{$vtype}->[1]};

        for my $type (@types) {
            if (_isa($args{$vtype}, 'ARRAY')) {
                push @{$args{$type}}, map { _expand_namespace($_, $vtype) } @{$args{$vtype}};
            } else {
                # _expand_namespace returns an empty list if $args{$vtype} is undef (or '')
                push @{$args{$type}}, _expand_namespace($args{$vtype}, $vtype);
            }
        }

        delete $args{$vtype};
    }

    my $bindings; # custom typemap

    # clone the bindings hash if available
    #
    # we may be assigning to it, and we don't want to contaminate outer/previous bindings
    # with nested/new bindings
    #
    # as of 5.10, references in %^H get stringified at runtime, but we don't need them then

    $bindings = $^H{autobox} ? { %{ $^H{autobox} } } : {};

    # sanity check %args, expand the namespace prefixes into class names,
    # and copy values to the $bindings hash

    my %synthetic = reverse(%$CLASS_CACHE); # synthetic class name => bool - see _pretty_print

    for my $type (keys %args) {
        # we've handled the virtual types, so we only need to check that this is a valid (real) type
        Carp::confess("unrecognized option: '", (defined $type ? $type : '<undef>'), "'") unless ($TYPES{$type});

        my (@isa, $class);

        if ($class = $bindings->{$type}) {
            @isa = $synthetic{$class} ? _get_isa($class) : ($class);
        }

        # perform namespace expansion; dups are removed in _generate_class below
        push @isa, map { _expand_namespace($_, $type) } @{$args{$type}};

        $bindings->{$type} = [ @isa ]; # assign the (possibly) new @isa for this type
    }

    # replace each array ref of classes with the name of the generated class.
    # if there's only one class in the type's @ISA (e.g. SCALAR => 'MyScalar') then
    # that class is used; otherwise a shim class whose @ISA contains the two or more classes
    # is created

    for my $type (keys %$bindings) {
        my $isa = $bindings->{$type};

        # delete empty arrays e.g. use autobox SCALAR => []
        if (@$isa == 0) {
            delete $bindings->{$type};
        } else {
            # associate the synthetic/single class with the specified type
            $bindings->{$type} = _generate_class($isa);
        }
    }

    # This turns on autoboxing i.e. the method call checker sets a flag on the method call op
    # and replaces its default handler with the autobox implementation.
    #
    # It needs to be set unconditionally because it may have been unset in unimport

    $^H |= 0x80020000; # set HINT_LOCALIZE_HH + an unused bit to work around a %^H bug

    # install the specified bindings in the current scope
    _install($bindings);

    # this is %^H as an integer - it changes as scopes are entered/exited
    # we don't need to stack/unstack it in %^H as %^H itself takes care of that
    # note: we need to call this *after* %^H is referenced (and possibly created) above

    my $scope = _scope();
    my $old_scope = exists($^H{autobox_scope})? $^H{autobox_scope} : 0;
    my $new_scope; # is this a new (top-level or nested) scope?

    if ($scope == $old_scope) {
        $new_scope = 0;
    } else {
        $^H{autobox_scope} = $scope;
        $new_scope = 1;
    }

    # warn "OLD ($old_scope) => NEW ($scope): $new_scope ", join(':', (caller(1))[0 .. 2]), $/;

    if ($debug) {
        $debug = \&_debug unless (_isa($debug, 'CODE'));
        $debug->(_pretty_print($bindings));
    }

    return unless ($new_scope);

    # This sub is called when this scope's $^H{autobox_leave} is deleted, usually when
    # %^H is destroyed at the end of the scope, but possibly directly in unimport()
    #
    # _enter splices in the autobox method call checker and method call op
    # if they're not already enabled
    #
    # _leave performs the necessary housekeeping to ensure that the default
    # checker and op are restored when autobox is no longer in scope

    my $guard = Scope::Guard->new(sub { _leave() });
    $^H{autobox_leave} = $guard;

    _enter();
}

# delete one or more bindings; if none remain, disable autobox in the current scope
#
# note: if bindings remain, we need to create a new hash (initially a clone of the current
# hash) so that the previous hash (if any) is not contaminated by new deletion(s)
#
#   use autobox;
#
#       "foo"->bar;
#
#   no autobox qw(SCALAR); # don't clobber the default bindings for "foo"->bar
#
# however, if there are no more bindings we can remove all traces of autobox from the
# current scope.

sub unimport {
    my ($class, @args) = @_;

    # the only situation in which there is no bindings hash is if this is a "no autobox"
    # that precedes any "use autobox", in which case we don't need to turn autoboxing off as it's
    # not yet been turned on
    return unless ($^H{autobox});

    my $bindings;

    if (@args) {
        $bindings = { %{$^H{autobox}} }; # clone the current bindings hash
        my %args = map { $_ => 1 } @args;

        # expand any virtual type "macros"
        for my $vtype (sort { $ISA{$a}->[0] <=> $ISA{$b}->[0] } keys %ISA) {
            next unless ($args{$vtype});

            # we could delete the types directly from $bindings here, but we may as well pipe them
            # through the option checker below to ensure correctness
            $args{$_} = 1 for (@{$ISA{$vtype}->[1]});

            delete $args{$vtype};
        }

        for my $type (keys %args) {
            # we've handled the virtual types, so we only need to check that this is a valid (real) type
            Carp::confess("unrecognized option: '", (defined $type ? $type : '<undef>'), "'") unless ($TYPES{$type});
            delete $bindings->{$type};
        }
    } else { # turn off autoboxing
        $bindings = {}; # empty hash to trigger full deletion below
    }

    if (%$bindings) {
        _install($bindings);
    } else { # remove all traces of autobox from the current scope
        $^H &= ~0x80020000; # unset HINT_LOCALIZE_HH + the additional bit
        delete $^H{autobox};
        delete $^H{autobox_scope};
        delete $^H{autobox_leave}; # triggers the leave handler
    }
}

1;
