#!/usr/bin/env perl

use strict;
use warnings;
use Cwd 'abs_path';
use DateTime;
use DateTime::Format::Strptime;
use Encode;
use Encode::Locale;
use File::Basename qw(dirname basename);
use File::Spec::Functions qw(abs2rel catfile);
use File::Temp;
use Getopt::Std;
use JSON;
use Path::Class;
use POSIX qw();
use WebService::Dropbox 2.04;

our $VERSION = '2.01';

my $limit = 10 * 1024 * 1024; # files_put_chunked method has large file support.

if ($^O eq 'darwin') {
    require Encode::UTF8Mac;
    $Encode::Locale::ENCODING_LOCALE_FS = 'utf-8-mac';
}

binmode STDOUT, ':utf8';
binmode STDERR, ':utf8';

my $config_file = file( $ENV{DROPBOX_CONF} || ($ENV{HOME} || $ENV{HOMEPATH}, '.dropbox-api-config') );

my $command = shift || '';
my @args;
for (@{[@ARGV]}) {
    last if $_=~/^-/;
    push @args, shift;
}

my %opts;
getopts('ndvDsherPp:', \%opts);

push @args, @ARGV;

my $dry       = $opts{n};
my $delete    = $opts{d};
my $verbose   = $opts{v};
my $debug     = $opts{D};
my $human     = $opts{h};
my $printf    = $opts{p};
my $public    = $opts{P};
my $env_proxy = $opts{e};

if ($opts{s}) {
    die "-s is gone.";
}

if ($command eq '-v') {
    &help('version');
    exit(0);
}

if ($command eq 'setup' || !-f $config_file) {
    &setup();
}

# connect dropbox
my $config = decode_json($config_file->slurp);
$config->{key} or die 'please set config key.';
$config->{secret} or die 'please set config secret.';
$config->{access_token} or die 'please set config access_token.';
if ($config->{access_secret}) {
    warn "Auto migration OAuth1 Token to OAuth2 token...";
    my $oauth2_access_token = &token_from_oauth1($config->{key}, $config->{secret}, $config->{access_token}, $config->{access_secret});
    if ($oauth2_access_token) {
        delete $config->{access_secret};
        $config->{access_token} = $oauth2_access_token;
        $config_file->openw->print(encode_json($config));
        warn "=> Suucess.";
    } else {
        die "please setup.";
    }
}
if (my $access_level = delete $config->{access_level}) {
    if ($access_level eq 'a') {
        print "sandbox is gone, Are you sure you want to delete from the config the access_level? [y/n]: ";
        chomp( my $y = <STDIN> );
        if ($y =~ qr{ [yY] }xms) {
            delete $config->{access_level};
            $config_file->openw->print(encode_json($config));
            warn "=> Suucess.";
        } else {
            die "cancelled.";
        }
    }
}

$ENV{HTTP_PROXY} = $ENV{http_proxy} if !$ENV{HTTP_PROXY} && $ENV{http_proxy};
$ENV{NO_PROXY} = $ENV{no_proxy} if !$ENV{NO_PROXY} && $ENV{no_proxy};

my $box = WebService::Dropbox->new($config);
$box->env_proxy if $env_proxy;

# printf option
my $strp;

my $format = {
    i => 'id',
    n => 'name',
    b => 'size',
    e => 'thumb_exists', # jpg, jpeg, png, tiff, tif, gif or bmp
    d => 'is_dir',       # Check if .tag = "folder"
    p => 'path_display',
    P => 'path_lower',
    s => 'format_size',
    t => 'server_modified',
    c => 'client_modified', # For files, this is the modification time set by the desktop client when the file was added to Dropbox.
    r => 'rev', # A unique identifier for the current revision of a file. This field is the same rev as elsewhere in the API and can be used to detect changes and avoid conflicts.
    R => 'rev',
};

# ProgressBar
my $cols = 50;
if ($verbose) {
    eval {
        my $stty = `stty -a 2>/dev/null`;
        if ($stty =~ m|columns (\d+)| || $stty =~ m|(\d+) columns|) {
            $cols = $1;
        }
    };
}

my $exit_code = 0;

if ($command eq 'ls' or $command eq 'list') {
    &list(@args);
} elsif ($command eq 'find') {
    &find(@args);
} elsif ($command eq 'copy' or $command eq 'cp') {
    &copy(@args);
} elsif ($command eq 'move' or $command eq 'mv') {
    &move(@args);
} elsif ($command eq 'mkdir' or $command eq 'mkpath') {
    &mkdir(@args);
} elsif ($command eq 'delete' or $command eq 'rm' or $command eq 'rmtree') {
    &delete(@args);
} elsif ($command eq 'upload' or $command eq 'up' or $command eq 'put') {
    &upload(@args);
} elsif ($command eq 'download' or $command eq 'dl' or $command eq 'get') {
    &download(@args);
} elsif ($command eq 'sync') {
    &sync(@args);
} elsif ($command eq 'help' or (not length $command)) {
    &help(@args);
} else {
    die "unknown command $command";
}

exit($exit_code);

sub help {
    my ($command) = @_;

    $command ||= '';

    my $help;
    if ($command eq 'ls' or $command eq 'list') {
        $help = q{
        Name
            dropbox-api-ls - list directory contents

        SYNOPSIS
            dropbox-api ls <dropbox_path> [options]

        Example
            dropbox-api ls Public
            dropbox-api ls Public -h
            dropbox-api ls Public -p "%d\t%s\t%TY/%Tm/%Td %TH:%TM:%TS\t%p\n"

        Options
            -h print sizes in human readable format (e.g., 1K 234M 2G)
            -p print format.
                %d ... is_dir ( d: dir, -: file )
                %i ... id
                %n ... name
                %p ... path_display
                %P ... path_lower
                %b ... bytes
                %s ... size (e.g., 1K 234M 2G)
                %t ... server_modified
                %c ... client_modified
                %r ... rev
                %Tk ... DateTime 'strftime' function (server_modified)
                %Ck ... DateTime 'strftime' function (client_modified)
        };
    } elsif ($command eq 'find') {
        $help = q{
        Name
            dropbox-api-find - walk a file hierarchy

        SYNOPSIS
            dropbox-api find <dropbox_path> [options]

        Example
            dropbox-api find Public
            dropbox-api find Public -h
            dropbox-api find Public -p "%d\t%s\t%TY/%Tm/%Td %TH:%TM:%TS\t%p\n"

        Options
            -h print sizes in human readable format (e.g., 1K 234M 2G)
            -p print format.
                %d ... is_dir ( d: dir, -: file )
                %i ... id
                %n ... name
                %p ... path_display
                %P ... path_lower
                %b ... bytes
                %s ... size (e.g., 1K 234M 2G)
                %t ... server_modified
                %c ... client_modified
                %r ... rev
                %Tk ... DateTime 'strftime' function (server_modified)
                %Ck ... DateTime 'strftime' function (client_modified)
        };
    } elsif ($command eq 'copy' or $command eq 'cp') {
        $help = q{
        Name
            dropbox-api-cp - copy file or directory

        SYNOPSIS
            dropbox-api cp <source_file> <target_file>

        Example
            dropbox-api cp Public/hoge.txt Public/foo.txt
            dropbox-api cp Public/work Public/work_bak
        };
    } elsif ($command eq 'move' or $command eq 'mv') {
        $help = q{
        Name
            dropbox-api-mv - move file or directory

        SYNOPSIS
            dropbox-api mv <source_file> <target_file>

        Example
            dropbox-api mv Public/hoge.txt Public/foo.txt
            dropbox-api mv Public/work Public/work_bak
        };
    } elsif ($command eq 'mkdir' or $command eq 'mkpath') {
        $help = q{
        Name
            dropbox-api-mkdir - make directory (Create intermediate directories as required)

        SYNOPSIS
            dropbox-api mkdir <directory>

        Example
            dropbox-api mkdir Public/product/chrome-extentions/foo
        };
    } elsif ($command eq 'delete' or $command eq 'rm' or $command eq 'rmtree') {
        $help = q{
        Name
            dropbox-api-rm - remove file or directory (Attempt to remove the file hierarchy rooted in each file argument)

        SYNOPSIS
            dropbox-api rm <file_or_directory>

        Example
            dropbox-api rm Public/work_bak/hoge.tmp
            dropbox-api rm Public/work_bak
        };
    } elsif ($command eq 'upload' or $command eq 'up' or $command eq 'put') {
        $help = q{
        Name
            dropbox-api-put - upload file

        SYNOPSIS
            dropbox-api put <file> dropbox:<dropbox_file>

        Example
            dropbox-api put README.md dropbox:/Public/product/dropbox-api/
        };
    } elsif ($command eq 'download' or $command eq 'dl' or $command eq 'get') {
        $help = q{
        Name
            dropbox-api-get - download file

        SYNOPSIS
            dropbox-api get dropbox:<dropbox_file> <file>

        Example
            dropbox-api get dropbox:/Public/product/dropbox-api/README.md README.md
        };
    } elsif ($command eq 'sync') {
        $help = q{
        Name
            dropbox-api-sync - sync directory

        SYNOPSIS
            dropbox-api sync dropbox:<source_dir> <target_dir> [options]
            dropbox-api sync <source_dir> dropbox:<target_dir> [options]

        Example
            dropbox-api sync dropbox:/Public/product/dropbox-api/ ~/work/dropbox-api/
            dropbox-api sync ~/work/dropbox-api/ dropbox:/Public/product/dropbox-api/ -vdn
            dropbox-api sync ~/work/dropbox-api/ dropbox:/Public/product/dropbox-api/ -d

        Options
            -v increase verbosity
            -n show what would have been transferred (dry-run)
            -d delete files that don't exist on sender
        };
    } elsif ($command eq 'version') {
        $help = qq{
        This is dropbox-api-command, version $VERSION

        Copyright 2016, Shinichiro Aska

        Released under the MIT license.

        Documentation
            this system using "dropbox-api help".
            If you have access to the Internet, point your browser at
            https://github.com/s-aska/dropbox-api-command,
            the dropbox-api-command Repository.
        };
    } else {
        $help = qq{
        Usage: dropbox-api <command> [args] [options]

        Available commands:
            setup get access_key and access_secret
            ls    list directory contents
            find  walk a file hierarchy
            cp    copy file or directory
            mv    move file or directory
            mkdir make directory (Create intermediate directories as required)
            rm    remove file or directory (Attempt to remove the file hierarchy rooted in each file argument)
            put   upload file
            get   download file
            sync  sync directory (local => dropbox or dropbox => local)

        Common Options
            -e enable env_proxy ( HTTP_PROXY, NO_PROXY )
            -D enable debug
            -v verbose

        See 'dropbox-api help <command>' for more information on a specific command.
        };
    }
    $help =~ s|^ {8}||mg;
    $help =~ s|^\s*\n||;
    print "\n$help\n";
}

sub setup {
    my $config = {};

    print "Please Input API Key: ";
    chomp( my $key = <STDIN> );
    die 'Get API Key from https://www.dropbox.com/developers' unless $key;
    $config->{key} = $key;

    print "Please Input API Secret: ";
    chomp( my $secret = <STDIN> );
    die 'Get API Secret from https://www.dropbox.com/developers' unless $secret;
    $config->{secret} = $secret;

    my $box = WebService::Dropbox->new($config);
    $box->env_proxy if $env_proxy;
    my $login_link = $box->authorize;
    die $box->error if $box->error;
    print "1. Open the Login URL: $login_link\n";
    print "2. Input code and press Enter: ";
    chomp( my $code = <STDIN> );
    unless ($box->token($code)) {
        die $box->error;
    }

    $config->{access_token} = $box->access_token;
    print "success! try\n";
    print "> dropbox-api ls\n";
    print "> dropbox-api find /\n";

    $config_file->openw->print(encode_json($config));

    chmod 0600, $config_file;

    exit(0);
}

sub list {
    my $remote_base = decode('locale_fs', slash(shift));
    my $list = $box->list_folder($remote_base) or die $box->error;
    for my $entry (@{ $list->{entries} }) {
        print &_line($entry);
    }
}

sub _line {
    my ($content) = @_;
    $strp ||= new DateTime::Format::Strptime( pattern => '%Y-%m-%dT%T' );
    my $dt;
    my $ct;
    my $get = sub {
        my $key = $format->{ $_[0] };
        if ($key eq 'format_size') {
            return exists $content->{size} ? format_bytes($content->{size}) : '   -';
        } elsif ($key eq 'is_dir') {
            $content->{'.tag'} eq 'folder' ? 'd' : '-';
        } elsif ($key eq 'thumb_exists') {
            if ($content->{path_display} =~ qr{ \.(?:jpg|jpeg|png|tiff|tif|gif|bmp) \z }xms && $content->{size} < 20 * 1024 * 1024) {
                return 'true';
            } else {
                return 'false';
            }
        } else {
            return exists $content->{ $key } ? $content->{ $key } : '-';
        }
    };
    if ($printf) {
        my $line = eval qq{"$printf"};
        if ($content->{server_modified}) {
            $line=~s/\%T([^\%])/
                $dt ||= $strp->parse_datetime($content->{server_modified});
                $dt->strftime('%'.$1);
            /egx;
        } else {
            $line=~s/\%TY/----/g;
            $line=~s/\%T([^\%])/--/g;
        }
        if ($content->{client_modified}) {
            $line=~s/\%C([^\%])/
                $ct ||= $strp->parse_datetime($content->{client_modified});
                $ct->strftime('%'.$1);
            /egx;
        } else {
            $line=~s/\%CY/----/g;
            $line=~s/\%C([^\%])/--/g;
        }
        $line=~s|\%([^\%])|$get->($1)|eg;
        return $line;
    } else {
        return sprintf "%s %8s %s %s\n",
            ($content->{'.tag'} eq 'folder' ? 'd' : '-'),
            $get->($human ? 's' : 'b'),
            $get->('t'),
            $content->{path_display};
    }
}

sub find {
    my $remote_base = decode('locale_fs', slash(shift));
    if ($remote_base !~ qr{ \A / }xms) {
        $remote_base = '/' . $remote_base;
    }
    $printf ||= "%p\n";
    my $list = $box->list_folder($remote_base, {
        recursive => JSON::true,
    }) or die $box->error;
    my @entries = sort { $a->{path_lower} cmp $b->{path_lower} } @{ $list->{entries} };
    for my $entry (@entries) {
        print &_line($entry);
    }
}

sub copy {
    my ($src, $dst) = @_;
    my $res = $box->copy(decode('locale_fs', slash($src)), decode('locale_fs', slash($dst))) or die $box->error;
    print pretty($res) if $verbose;
}

sub move {
    my ($src, $dst) = @_;
    my $res = $box->move(decode('locale_fs', slash($src)), decode('locale_fs', slash($dst))) or die $box->error;
    print pretty($res) if $verbose;
}

sub mkdir {
    my ($dir) = @_;
    my $res = $box->create_folder(decode('locale_fs', slash($dir))) or die $box->error;
    print pretty($res) if $verbose;
}

sub delete {
    my ($file_or_dir) = @_;
    my $res = $box->delete(decode('locale_fs', slash($file_or_dir))) or die $box->error;
    print pretty($res) if $verbose;
}

sub upload {
    my ($file, $path) = @_;
    $path =~ s|^dropbox:/|/|
        or die "Usage: \n    dropbox-api upload /tmp/local.txt dropbox:/Public/some.txt";
    my $local_path = file($file);
    if ((! length $path) or $path =~ m|/$|) {
        $path.= basename($file);
    }
    my $res = &put($local_path, decode('locale_fs', $path)) or die $box->error;

    if ($verbose) {
        print pretty($res);
    }

    my $id = $res->{id};

    if ($public) {
        my $list_shared_links = $box->api({
            url => 'https://api.dropboxapi.com/2/sharing/list_shared_links',
            params => {
                path => $id,
            }
        }) or die $box->error;
        for (@{ $list_shared_links->{links} }) {
            if ($id eq $_->{id} && $_->{link_permissions}{resolved_visibility}{'.tag'} eq 'public') {
                print $_->{url}, "\n";
                return;
            }
        }

        my $res = $box->api({
            url => 'https://api.dropboxapi.com/2/sharing/create_shared_link_with_settings',
            params => {
                path => $path,
                settings => {
                    requested_visibility => 'public',
                }
            }
        }) or die $box->error;
        print $res->{url}, "\n";
    }
}

sub download {
    my ($path, $file) = @_;
    $path=~s|^dropbox:/|/|
        or die "Usage: \n    dropbox-api download dropbox:/Public/some.txt /tmp/local.txt";
    my $fh = file($file)->openw or die $!;
    $box->download(decode('locale_fs', $path), $fh) or die $box->error;
    $fh->close;
}

sub sync {
    my ($arg1, $arg2) = @_;

    my ($remote_base, $local_base, $command);
    if ($arg1 =~ qr{ \A dropbox: }xms and $arg2 !~ qr{ \A dropbox: }xms) {
        ($remote_base, $local_base) = ($arg1, $arg2);
        $command = \&sync_download;
    } elsif ($arg1 !~ qr{ \A dropbox: }xms and $arg2 =~ qr{ \A dropbox: }xms) {
        ($remote_base, $local_base) = ($arg2, $arg1);
        $command = \&sync_upload;
    } else {
        die "Usage: \n    dropbox-api sync dropbox:/Public/ /tmp/pub/\n" .
                   "or    dropbox-api sync /tmp/pub/ dropbox:/Public/";
    }

    die "missing $local_base" unless -d $local_base;

    $local_base = dir(abs_path($local_base));

    $remote_base = decode('locale_fs', $remote_base);

    $remote_base =~ s|^dropbox:/|/|;

    my $content = $box->get_metadata($remote_base) or die $box->error;

    $remote_base = $content->{path_display}; # Dropbox are case insensitive!

    print "!! enable dry run !!\n" if $dry;
    print "remote_base: $remote_base\n" if $verbose;
    print "local_base: $local_base\n" if $verbose;

    $command->($remote_base, $local_base);
}

sub sync_download {
    my ($remote_base, $local_base) = @_;

    print "** download **\n" if $verbose;

    my $strp = new DateTime::Format::Strptime( pattern => '%Y-%m-%dT%T' );

    my $remote_map = {};
    my $remote_inode_map = {};

    my $list = $box->list_folder($remote_base, {
        recursive => JSON::true,
    }) or die $box->error;
    my @entries = sort { $a->{path_lower} cmp $b->{path_lower} } @{ $list->{entries} };
    for my $content (@entries) {
        my $remote_path = $content->{path_display};
        my $rel_path = remote_abs2rel($remote_path, $remote_base);
        unless (length $rel_path) {
            next;
        }
        my $rel_path_enc = encode('locale_fs', $rel_path);
        $remote_map->{$rel_path}++;
        printf "check: %s\n", $rel_path if $debug;
        my $is_dir = $content->{'.tag'} eq 'folder' ? 1 : 0;
        my $local_path = $is_dir ? dir($local_base, $rel_path_enc) : file($local_base, $rel_path_enc);
        if ($is_dir) {
            printf "remote: %s\n", $remote_path if $debug;
            printf "local:  %s\n", $local_path if $debug;
            if (!-d $local_path) {
                $local_path->mkpath unless $dry;
                printf "mkpath %s\n", decode('locale_fs', $local_path);
            } else {
                printf "skip %s\n", $rel_path if $verbose;
            }
        } else {
            my $remote_epoch = $strp->parse_datetime($content->{client_modified})->epoch;
            my $local_epoch = -f $local_path ? $local_path->stat->mtime : '-';
            my $remote_size = $content->{size};
            my $local_size = -f $local_path ? $local_path->stat->size : '-';
            if ($debug) {
                printf "remote: %10s %10s %s\n", $remote_epoch, $remote_size, $remote_path;
                printf "local:  %10s %10s %s\n", $local_epoch, $local_size, $local_path;
            }

            if ((!-f $local_path) || ($remote_size != $local_size) || ($remote_epoch > $local_epoch)) {

                if ($dry) {
                    printf "download %s\n", decode('locale_fs', $local_path);
                    next;
                }

                # not displayed in the dry-run for the insurance
                unless (-d $local_path->dir) {
                    printf "mkpath %s\n", decode('locale_fs', $local_path->dir);
                    $local_path->dir->mkpath;
                }

                my $local_path_tmp = $local_path . '.dropbox-api.tmp';
                my $fh;
                unless (open($fh, '>', $local_path_tmp)) {
                    warn "open failure " . decode('locale_fs', $local_path) . " (" . $! . ")";
                    $exit_code = 1;
                    next;
                }
                if ($box->download($content->{path_display}, $fh)) {
                    printf "download %s\n", decode('locale_fs', $local_path);
                    close($fh);
                    unless (utime($remote_epoch, $remote_epoch, $local_path_tmp)) {
                        warn "set modification time failure " .  decode('locale_fs', $local_path);
                        $exit_code = 1;
                    }
                    unless (rename($local_path_tmp, $local_path)) {
                        unlink($local_path_tmp);
                        warn "rename failure " . decode('locale_fs', $local_path_tmp);
                        $exit_code = 1;
                    }
                } else {
                    unlink($local_path_tmp);
                    chomp( my $error = $box->error );
                    warn "download failure " . decode('locale_fs', $local_path) . " (" . $error . ")";
                    $exit_code = 1;
                }
            } else {
                printf "skip %s\n", $rel_path if $verbose;
            }
        }
        $remote_inode_map->{ &inode($local_path) } = $content;
    }

    if ($exit_code) {
        return;
    }

    unless ($delete) {
        return;
    }

    if ($verbose) {
        print "** delete **\n";
    }

    my @deletes;
    $local_base->recurse(
        preorder => 0,
        depthfirst => 1,
        callback => sub {
            my $local_path = shift;
            if ($local_path eq $local_base) {
                return;
            }

            my $rel_path_enc = abs2rel($local_path, $local_base);
            my $rel_path = decode('locale_fs', $rel_path_enc);

            if (exists $remote_map->{$rel_path}) {
                if ($verbose) {
                    printf "skip %s\n", $rel_path;
                }
            } elsif (my $content = $remote_inode_map->{ &inode($local_path) }) {
                my $remote_path = $content->{path_display};
                my $rel_path_remote = remote_abs2rel($remote_path, $remote_base);
                if ($verbose) {
                    if ($debug) {
                        printf "skip %s ( is %s )\n", $rel_path, $rel_path_remote;
                    } else {
                        printf "skip %s\n", $rel_path;
                    }
                }
            } elsif (-f $local_path) {
                printf "remove %s\n", $rel_path;
                push @deletes, $local_path;
            } elsif (-d $local_path) {
                printf "rmtree %s\n", $rel_path;
                push @deletes, $local_path;
            }
        }
    );

    if ($dry) {
        return;
    }

    for my $local_path (@deletes) {
        if (-f $local_path) {
            $local_path->remove;
        } elsif (-d $local_path) {
            $local_path->rmtree;
        }
    }
}

sub sync_upload {
    my ($remote_base, $local_base) = @_;

    print "** upload **\n" if $verbose;

    my $strp = new DateTime::Format::Strptime( pattern => '%Y-%m-%dT%T' );

    my $remote_map = {};
    my $remote_path_map = {};

    my $list = $box->list_folder($remote_base, {
        recursive => JSON::true,
    }) or die $box->error;
    my @entries = sort { $a->{path_lower} cmp $b->{path_lower} } @{ $list->{entries} };
    for my $content (@entries) {
        my $remote_path = $content->{path_display};
        my $rel_path = remote_abs2rel($remote_path, $remote_base);
        unless (length $rel_path) {
            next;
        }
        $remote_map->{ $rel_path } = $content;
        $remote_path_map->{ $content->{path_display} } = $content;
        if ($debug) {
            printf "find: %s\n", $rel_path;
        }
    }
    my @makedirs;
    $local_base->recurse(
        preorder => 0,
        depthfirst => 1,
        callback => sub {
            my $local_path = shift;
            if ($local_path eq $local_base) {
                return;
            }
            my $rel_path = decode('locale_fs', abs2rel($local_path, $local_base));
            my $remote_path = file($remote_base, $rel_path);
            my $content = delete $remote_map->{ $rel_path } || $box->get_metadata("$remote_path"); # Dropbox are case insensitive!

            # exists file or directory
            if ($content) {
                delete $remote_path_map->{ $content->{path_display} };

                unless (-f $local_path) {
                    return;
                }

                my $remote_epoch = $strp->parse_datetime($content->{client_modified})->epoch;
                my $local_epoch = $local_path->stat->mtime;
                my $remote_size = $content->{size};
                my $local_size = $local_path->stat->size;

                if ($debug) {
                    printf "remote: %10s %10s %s\n", $remote_epoch, $remote_size, $content->{path_display};
                    printf "local:  %10s %10s %s\n", $local_epoch, $local_size, decode('locale_fs', $local_path);
                }

                if (($remote_size != $local_size) || ($remote_epoch < $local_epoch)) {
                    if ($remote_size == $local_size) {
                        $box->delete("$remote_path");
                    }
                    printf "upload %s %s\n", $rel_path, $remote_path;
                    unless ($dry) {
                        &put($local_path, $remote_path, { client_modified => $strp->format_datetime(DateTime->from_epoch( epoch => $local_epoch )) . 'Z' }) or die $box->error;
                    }
                    push @makedirs, $rel_path;
                } elsif ($verbose) {
                    printf "skip %s\n", $rel_path;
                }
            }

            # new file
            elsif (-f $local_path) {
                unless ($dry) {
                    my $local_epoch = $local_path->stat->mtime;
                    &put($local_path, $remote_path, { client_modified => $strp->format_datetime(DateTime->from_epoch( epoch => $local_epoch )) . 'Z' });
                }
                if (!$dry && $box->error) {
                    warn "upload failure $rel_path $remote_path (" . $box->error . ")";
                } else {
                    printf "upload %s %s\n", $rel_path, $remote_path;
                    push @makedirs, $rel_path;
                }
            }

            # new directory
            elsif (-d $local_path) {

                if (grep { $_ =~ qr{ \A\Q$rel_path }xms } @makedirs) {
                    return;
                }

                printf "mktree %s %s\n", $rel_path, $remote_path;

                unless ($dry) {
                    $box->create_folder($remote_path) or die $box->error;
                }

                push @makedirs, $rel_path;
            } else {
                printf "unknown %s\n", $rel_path;
            }
        }
    );

    return unless $delete;

    print "** delete **\n" if $verbose;

    my @deletes;
    for my $content_path ( keys %$remote_path_map ) {

        if (grep { $content_path =~ qr{ \A\Q$_ }xms } @deletes) {
            next;
        }

        unless ($dry) {
            $box->delete($content_path) or die $box->error;
        }

        push @deletes, $content_path;

        printf "delete %s\n", remote_abs2rel($content_path, $remote_base);
    }
}

sub put {
    my ($file, $path, $optional_params) = @_;

    my $commit_params = {
        path => "$path",
        mode => 'overwrite',
        %{ $optional_params || +{} },
    };

    my $content = $file->openr;
    my $size = -s $file;
    my $threshold = 10 * 1024 * 1024;

    if ($size < $threshold || !$verbose) {
        return $box->upload("$path", $content, $commit_params);
    }

    my $session_id;
    my $offset = 0;

    my $limit = 4 * 1024 * 1024;

    $| = 1;

    my $upload;
    $upload = sub {
        my $buf;
        my $total = 0;
        my $chunk = 1024;
        my $tmp = File::Temp->new;
        my $is_last;
        while (my $read = read($content, $buf, $chunk)) {
            $tmp->print($buf);
            $total += $read;
            my $remaining = $limit - $total;
            if ($chunk > $remaining) {
                $chunk = $remaining;
            }
            unless ($chunk) {
                last;
            }
        }

        $tmp->flush;
        $tmp->seek(0, 0);

        # finish or small file
        if ($total < $limit) {
            if ($session_id) {
                my $params = {
                    cursor => {
                        session_id => $session_id,
                        offset     => $offset,
                    },
                    commit => $commit_params,
                };
                return $box->upload_session_finish($tmp, $params);
            } else {
                return $box->upload("$path", $tmp, $commit_params);
            }
        }

        # append
        elsif ($session_id) {
            my $params = {
                cursor => {
                    session_id => $session_id,
                    offset     => $offset,
                },
            };
            unless ($box->upload_session_append_v2($tmp, $params)) {
                # some error
                return;
            }
            $offset += $total;
        }

        # start
        else {
            my $res = $box->upload_session_start($tmp);
            if ($res && $res->{session_id}) {
                $session_id = $res->{session_id};
                $offset = $total;
            } else {
                # some error
                return;
            }
        }

        # ProgressBar
        my $rate = sprintf('%2.1d%%', $offset / $size * 100);
        my $bar = '=' x int(($cols - length($rate) - 4) * $offset / $size);
        my $space = ' ' x ($cols - length($rate) - length($bar) - 4);
        printf "\r%s [%s>%s]", $rate, $bar, $space;

        $upload->();
    };
    $upload->();
}

sub inode ($) {
    my $path = shift;
    my ($dev, $inode) = stat($path);
    return $dev . ':' . $inode if $inode;
    return $path;
}

sub remote_abs2rel ($$) {
    my ($remote_path, $remote_base) = @_;
    $remote_path =~ s|^\Q$remote_base\E/?||i;
    return $remote_path;
}

sub slash ($) {
    if ($_[0] !~ qr{ \A / }xms) {
        $_[0] = '/' . $_[0];
    }
    $_[0];
}

sub pretty($) {
    JSON->new->utf8->pretty->encode($_[0]);
}

use constant UNITS => [
    [ 'P', 1024 ** 4 * 1000, 1024 ** 5 ],
    [ 'T', 1024 ** 3 * 1000, 1024 ** 4 ],
    [ 'G', 1024 ** 2 * 1000, 1024 ** 3 ],
    [ 'M', 1024 * 1000, 1024 ** 2 ],
    [ 'K', 1000, 1024 ],
    [ 'B', 0, 1 ],
];

sub format_bytes ($) {
    my $size = shift;
    for my $unit (@{ UNITS() }) {
        my ($unit_label, $unit_min, $unit_value) = @{ $unit };
        if ($size >= $unit_min) {
            my $size_unit = $size / $unit_value;
            if (round($size_unit) < 10) {
                return sprintf('%1.1f%s', nearest(.1, $size_unit), $unit_label);
            } else {
                return sprintf('%3s%s', round($size_unit), $unit_label);
            }
        }
    }
    return '  0B';
}

sub round ($) {
    POSIX::floor($_[0] + 0.50000000000008);
}

sub nearest ($) {
    round($_[1] / $_[0]) * $_[0];
}

sub token_from_oauth1 {
    my $key = shift;
    my $secret = shift;
    my $access_token = shift;
    my $access_secret = shift;

    require WebService::Dropbox::TokenFromOAuth1;

    WebService::Dropbox::TokenFromOAuth1->token_from_oauth1({
        consumer_key    => $key,
        consumer_secret => $secret,
        access_token    => $access_token,  # OAuth1 access_token
        access_secret   => $access_secret, # OAuth2 access_secret
    });
}

exit(0);
