package FixMyStreet::App::Model::PhotoSet;

# TODO this isn't a Cat model, rename to something else

use Moose;

use Scalar::Util 'openhandle', 'blessed';
use Image::Size;
use IPC::Cmd qw(can_run);
use IPC::Open3;
use Path::Tiny;
use Try::Tiny;

use FixMyStreet;
use FixMyStreet::ImageMagick;
use FixMyStreet::PhotoStorage;

# Attached Catalyst app, if present, for feeding back errors during photo upload
has c => (
    is => 'ro',
);

# The attached report, for using its ID
has object => (
    is => 'ro',
);

# The attached report's ID, if present
has object_id => (
    is => 'ro',
    lazy => 1,
    default => sub {
        my $self = shift;
        return undef unless $self->object;
        return $self->object->id;
    },
);

# The attached report's type, if known
has object_type => (
    is => 'ro',
    lazy => 1,
    default => sub {
        my $self = shift;
        return undef unless $self->object;
        return ref $self->object eq 'FixMyStreet::DB::Result::Comment' ? 'comment' : 'problem';
    },
);

# Whether thumbnails etc generated by this class should generate a local cache file
has cacheable => (
    is => 'ro',
);

# If a PhotoSet is generated from a database row, db_data is set, which then
# fills data_items -> ids -> data. If it is generated during creation,
# data_items is set, which then similarly fills ids -> data.

has db_data => ( # generic data from DB field
    is => 'ro',
);

has data => ( # String of photo hashes
    is => 'ro',
    lazy => 1,
    default => sub {
        my $self = shift;
        my $data = join ',', $self->all_ids;
        return $data;
    }
);

has data_items => ( # either a) split from db_data or b) provided by photo upload
    isa => 'ArrayRef',
    is => 'ro',
    traits => ['Array'],
    lazy => 1,
    handles => {
        map_data_items => 'map',
    },
    default => sub {
        my $self = shift;
        my $data = $self->db_data or return [];

        return [$data] if ($self->storage->detect_type($data));

        return [ split ',' => $data ];
    },
);

has storage => (
    is => 'ro',
    lazy => 1,
    default => sub {
        return FixMyStreet::PhotoStorage::backend;
    }
);

has symlinkable => (
    is => 'ro',
    lazy => 1,
    default => sub {
        my $cfg = FixMyStreet->config('PHOTO_STORAGE_OPTIONS');
        return $cfg ? $cfg->{SYMLINK_FULL_SIZE} : 0;
    }
);

=head2 C<ids>, C<num_images>, C<get_id>, C<all_ids>

C<$photoset-E<GT>ids> is an arrayref containing the fileid data.

    [ $fileid1, $fileid2, ... ]

Various accessors are provided onto it:

    num_images: count
    get_id ($index): return the correct id
    all_ids: array of elements, rather than arrayref

=cut

has ids => ( #  Arrayref of $fileid tuples (always, so post upload/raw data processing)
    isa => 'ArrayRef',
    is => 'ro',
    traits => ['Array'],
    lazy => 1,
    handles => {
        num_images => 'count',
        get_id => 'get',
        all_ids => 'elements',
    },
    default => sub {
        my $self = shift;
        my @photos = $self->map_data_items( sub {
            my $part = $_;

            if (blessed $part and $part->isa('Catalyst::Request::Upload')) {
                my $upload = $part;
                my $ct = $upload->type;
                $ct =~ s/x-citrix-//; # Thanks, Citrix
                my ($type) = $ct =~ m{image/(jpeg|pjpeg|gif|tiff|png)};
                $type = 'jpeg' if $type && $type eq 'pjpeg';
                # Had a report of a JPEG from an Android 2.1 coming through as a byte stream
                $type = 'jpeg' if !$type && $ct eq 'application/octet-stream';
                unless ( $type ) {
                    my $c = $self->c;
                    $c->log->info('Bad photo tried to upload, type=' . $ct);
                    $c->stash->{photo_error} = _('Please upload an image only');
                    return ();
                }

                # Make sure any base64 encoding is handled.
                FixMyStreet::PhotoStorage::base64_decode_upload($self->c, $upload);

                # get the photo into a variable
                my $photo_blob = eval {
                    my $filename = $upload->tempname;
                    my $out;
                    if ($type eq 'jpeg' && can_run('jhead')) {
                        my $pid = open3(undef, my $stdout, undef, 'jhead', '-se', '-autorot', $filename);
                        $out = join('', <$stdout>);
                        waitpid($pid, 0);
                        close $stdout;
                    }
                    unless (defined $out) {
                        my ($w, $h, $err) = Image::Size::imgsize($filename);
                        die _("Please upload an image only") . "\n" if !defined $w || $err !~ /JPG|GIF|PNG|TIF/;
                    }
                    die _("Please upload an image only") . "\n" if $out && $out =~ /Not JPEG:/;
                    my $photo = $upload->slurp;
                };
                if ( my $error = $@ ) {
                    my $format = _(
            "That image doesn't appear to have uploaded correctly (%s), please try again."
                    );
                    $self->c->stash->{photo_error} = sprintf( $format, $error );
                    return ();
                }

                # Convert all images to JPEGs
                my %params = ( magick => 'JPEG' );

                # we have an image we can use - save it to storage
                $photo_blob = try {
                    FixMyStreet::ImageMagick->new(blob => $photo_blob)->shrink('2048x2048')->as_blob(%params);
                } catch { $photo_blob };
                return $self->storage->store_photo($photo_blob);
            }

            # It might be a raw file stored in the DB column...
            if (my $type = $self->storage->detect_type($part)) {
                my $photo_blob = $part;
                return $self->storage->store_photo($photo_blob);
                # TODO: Should this update the DB record with a pointer to the
                # newly-stored file, instead of leaving it in the DB?
            }

            if (my $key = $self->storage->validate_key($part)) {
                $key;
            } else {
                # A bad hash, probably a bot spamming with bad data.
                ();
            }
        });
        return \@photos;
    },
);

sub get_image_type {
    my ($self, $index) = @_;
    my $filename = $self->get_id($index);
    my ($fileid, $type) = split /\./, $filename;
    return $type;
}

sub get_mime_type {
    my ($self, $index) = @_;
    return "image/" . $self->get_image_type($index);
}

sub get_raw_image {
    my ($self, $index) = @_;
    my $filename = $self->get_id($index);
    my $type = $self->get_image_type($index);
    my ($photo, $object) = $self->storage->retrieve_photo($filename);
    if ($photo) {
        return {
            $object ? (object => $object) : (),
            data => $photo,
            content_type => "image/$type",
            extension => $type,
        };
    }
}

sub get_image_data {
    my ($self, %args) = @_;
    my $num = $args{num} || 0;
    my $size = $args{size};

    if (my $cached = $self->get_cached($num, $size)) {
        return $cached;
    }

    my $image = $self->get_raw_image( $num )
        or return;

    if ($self->symlinkable && $image->{object} && $size eq 'full') {
        $image->{symlink} = delete $image->{object};
        return $image;
    }

    my $im = FixMyStreet::ImageMagick->new(blob => $image->{data});
    my $photo = try {
        if ( $size eq 'tn' ) {
            $im->shrink('x100');
        } elsif ( $size eq 'fp' ) {
            $im->crop;
        } elsif ( $size eq 'og' ) {
            $im->crop('1200x630');
        } elsif ( $size eq 'full' ) {
            $im
        } else {
            $im->shrink($args{default} || '250x250');
        }
    };
    return unless $photo;

    my $blob = $photo->as_blob;
    $self->cache($blob, $num, $size);

    return {
        data => $blob,
        content_type => $image->{content_type},
    };
}

sub _construct_path {
    my ($self, $num, $size) = @_;

    my $id = $self->object_id or return;
    my $type = $self->get_image_type($num);
    $size = ".$size" if $size;

    my $path = "$id.$num$size.$type";
    $path = "c/$path" if $self->object_type eq 'comment';
    $path = path(FixMyStreet->path_to('web', 'photo', $path));
    return ($path, $type);
}

sub get_cached {
    my ($self, $num, $size) = @_;

    my ($in, $type) = $self->_construct_path($num, $size) or return;
    if ($in->exists) {
        return {
            data => $in->slurp_raw,
            content_type => "image/$type",
        };
    }
}

sub cache {
    my ($self, $photo, $num, $size) = @_;

    return if FixMyStreet->config('LOGIN_REQUIRED') || !$self->cacheable;

    my ($out, $type) = $self->_construct_path($num, $size) or return;
    path(FixMyStreet->path_to('web', 'photo', 'c'))->mkdir;
    $out->spew_raw($photo);
}

sub delete_cached {
    my ($self, %params) = @_;
    my $object = $self->object or return;
    my $id = $object->id or return;

    my @dirs = ('web', 'photo');
    push @dirs, 'c' if $self->object_type eq 'comment';

    my @photo_types = ("", ".fp", ".tn", ".full", ".og");

    # Old files without an index number; will always be .jpeg
    foreach my $size (@photo_types) {
        unlink FixMyStreet->path_to(@dirs, "$id$size.jpeg");
    }

    # New files with index number
    my @images = $self->all_ids;
    foreach my $i (0.. $#images) {
        my $type = $self->get_image_type($i);
        foreach my $size (@photo_types) {
            unlink FixMyStreet->path_to(@dirs, "$id.$i$size.$type");
        }
    }

    # Loop through all the updates as well if requested
    if ($params{plus_updates}) {
        $_->get_photoset->delete_cached() foreach $object->comments->all;
    }
}

sub keep_images {
    my ($self, $ids) = @_;

    my @images = $self->all_ids;
    @images = @images[grep { $_ >= 0 && $_ < @images } sort @$ids];

    $self->delete_cached();

    my $new_set = (ref $self)->new({
        data_items => \@images,
        object => $self->object,
    });

    return $new_set;
}

sub remove_images {
    my ($self, $ids) = @_;

    my @images = $self->all_ids;
    my $dec = 0;
    for (sort { $a <=> $b } @$ids) {
        splice(@images, $_ + $dec, 1);
        --$dec;
    }

    $self->delete_cached();

    return undef if !@images;

    my $new_set = (ref $self)->new({
        data_items => \@images,
        object => $self->object,
    });

    return $new_set->data; # e.g. new comma-separated fileid
}

sub rotate_image {
    my ($self, $index, $direction) = @_;

    my @images = $self->all_ids;
    return if $index > $#images;

    my $image = $self->get_raw_image($index);
    $images[$index] = FixMyStreet::ImageMagick->new(blob => $image->{data})->rotate($direction)->as_blob;

    my $new_set = (ref $self)->new({
        data_items => \@images,
        object => $self->object,
    });

    $self->delete_cached();

    return $new_set->data; # e.g. new comma-separated fileid
}

sub redact_image {
    my ($self, $index, $rects, $size) = @_;

    my @images = $self->all_ids;
    return if $index > $#images;

    my $image = $self->get_raw_image($index);
    $images[$index] = FixMyStreet::ImageMagick->new(blob => $image->{data})->redact($rects, $size)->as_blob;

    my $new_set = (ref $self)->new({
        data_items => \@images,
        object => $self->object,
    });

    $self->delete_cached();

    return $new_set;
}

# Shrinks any images over the given size until they are small enough.
# First tries shrinking to the given percentage of the original size.
# If this isn't small enough, next tries shrinking the original to the given percentage squared,
# and so on.
# E.g. for 90% it would next try 81%, then 72% etc.
# Returns the new photoset and a bool indicating if any images were shrunk.
sub shrink_all_to_size {
    my ($self, $size_bytes, $resize_percent) = @_;

    my $shrunk = 0;
    my @images = $self->all_ids;
    foreach my $i (0.. $#images) {
        my $original_blob = $self->get_raw_image($i)->{data};

        if (length $original_blob <= $size_bytes) {
            next;
        }

        $shrunk = 1;

        my $percent = $resize_percent;
        my $shrunk_blob;
        do {
            $shrunk_blob = FixMyStreet::ImageMagick->new(blob => $original_blob)
                            ->shrink_to_percentage($percent)
                            ->as_blob;
            $percent = $percent * $resize_percent;
        } while (length $shrunk_blob > $size_bytes);

        $images[$i] = $shrunk_blob;
    }

    my $new_set = (ref $self)->new({
        data_items => \@images,
        object => $self->object,
    });
    $self->delete_cached();
    return ($new_set, $shrunk);
}

1;
